checkpoint: focus seed 1.0.1 localized dev switch
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../core/date_keys.dart';
|
||||
import '../data/focus_seed_store.dart';
|
||||
import '../models/app_state.dart';
|
||||
import '../models/focus_session.dart';
|
||||
import '../models/habit.dart';
|
||||
import '../models/mood.dart';
|
||||
import '../models/reflection_entry.dart';
|
||||
import '../services/analytics_provider.dart';
|
||||
|
||||
enum FocusSeedError { openFailed, saveFailed }
|
||||
|
||||
class FocusSeedController extends ChangeNotifier {
|
||||
FocusSeedController({
|
||||
FocusSeedStore? store,
|
||||
AnalyticsProvider? analytics,
|
||||
List<String> defaultHabitTitles = const [],
|
||||
}) : _store = store ?? FocusSeedStore(),
|
||||
_analytics = analytics ?? const NoopAnalyticsProvider(),
|
||||
_defaultHabitTitles = List.unmodifiable(defaultHabitTitles);
|
||||
|
||||
final FocusSeedStore _store;
|
||||
final AnalyticsProvider _analytics;
|
||||
List<String> _defaultHabitTitles;
|
||||
|
||||
AppState? _state;
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
FocusSeedError? _error;
|
||||
|
||||
AppState? get state => _state;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isSaving => _isSaving;
|
||||
FocusSeedError? get error => _error;
|
||||
|
||||
void updateDefaultHabitTitles(List<String> titles) {
|
||||
_defaultHabitTitles = List.unmodifiable(titles);
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
_state = await _store.load(defaultHabitTitles: _defaultHabitTitles);
|
||||
} on Object {
|
||||
_error = FocusSeedError.openFailed;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> setTodayAnchor(String value) async {
|
||||
final current = _state;
|
||||
final anchor = value.trim();
|
||||
if (current == null || anchor.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
_analytics.track('intention_saved');
|
||||
return _commit(current.copyWith(anchor: anchor, anchorDateKey: todayKey()));
|
||||
}
|
||||
|
||||
Future<bool> toggleHabit(Habit habit) async {
|
||||
final current = _state;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
final today = todayKey();
|
||||
final completedDates = Set<String>.from(habit.completedDates);
|
||||
if (completedDates.contains(today)) {
|
||||
completedDates.remove(today);
|
||||
} else {
|
||||
completedDates.add(today);
|
||||
await HapticFeedback.lightImpact();
|
||||
}
|
||||
final habits = current.habits
|
||||
.map(
|
||||
(item) =>
|
||||
item.id == habit.id
|
||||
? habit.copyWith(completedDates: completedDates)
|
||||
: item,
|
||||
)
|
||||
.toList(growable: false);
|
||||
_analytics.track('habit_toggled');
|
||||
return _commit(current.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<bool> addHabit(String value) async {
|
||||
final current = _state;
|
||||
final title = value.trim();
|
||||
if (current == null || title.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final habit = Habit(
|
||||
id: 'habit-${now.microsecondsSinceEpoch}',
|
||||
title: title,
|
||||
createdAt: now,
|
||||
completedDates: const {},
|
||||
);
|
||||
_analytics.track('habit_created');
|
||||
return _commit(current.copyWith(habits: [...current.habits, habit]));
|
||||
}
|
||||
|
||||
Future<bool> renameHabit(Habit habit, String value) async {
|
||||
final current = _state;
|
||||
final title = value.trim();
|
||||
if (current == null || title.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final habits = current.habits
|
||||
.map((item) => item.id == habit.id ? item.copyWith(title: title) : item)
|
||||
.toList(growable: false);
|
||||
return _commit(current.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<bool> deleteHabit(Habit habit) async {
|
||||
final current = _state;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
final habits = current.habits
|
||||
.where((item) => item.id != habit.id)
|
||||
.toList(growable: false);
|
||||
return _commit(current.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<bool> completeFocusSession(int minutes) async {
|
||||
final current = _state;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final session = FocusSession(
|
||||
id: 'session-${now.microsecondsSinceEpoch}',
|
||||
minutes: minutes,
|
||||
completedAt: now,
|
||||
);
|
||||
_analytics.track('focus_completed', {'minutes': minutes});
|
||||
return _commit(current.copyWith(sessions: [...current.sessions, session]));
|
||||
}
|
||||
|
||||
Future<bool> saveReflection(String value, Mood mood) async {
|
||||
final current = _state;
|
||||
final note = value.trim();
|
||||
if (current == null || note.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final today = todayKey();
|
||||
final now = DateTime.now();
|
||||
final entry = ReflectionEntry(
|
||||
id: 'reflection-${now.microsecondsSinceEpoch}',
|
||||
dateKey: today,
|
||||
note: note,
|
||||
mood: mood,
|
||||
createdAt: now,
|
||||
);
|
||||
final reflections = [
|
||||
...current.reflections.where((item) => item.dateKey != today),
|
||||
entry,
|
||||
];
|
||||
_analytics.track('reflection_saved');
|
||||
return _commit(current.copyWith(reflections: reflections));
|
||||
}
|
||||
|
||||
Future<bool> importState(String raw) async {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return false;
|
||||
}
|
||||
return _commit(AppState.fromJson(decoded));
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> clearData() async {
|
||||
return _commit(AppState.starter(defaultHabitTitles: _defaultHabitTitles));
|
||||
}
|
||||
|
||||
Future<bool> _commit(AppState nextState) async {
|
||||
final previousState = _state;
|
||||
_state = nextState;
|
||||
_isSaving = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _store.save(nextState);
|
||||
return true;
|
||||
} on Object {
|
||||
_state = previousState;
|
||||
_error = FocusSeedError.saveFailed;
|
||||
return false;
|
||||
} finally {
|
||||
_isSaving = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
enum FocusTimerStatus { idle, running, paused, completed }
|
||||
|
||||
class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
FocusTimerController({
|
||||
required Future<void> Function(int minutes) onCompleted,
|
||||
int initialMinutes = 25,
|
||||
}) : _onCompleted = onCompleted,
|
||||
_selectedMinutes = initialMinutes,
|
||||
_remainingSeconds = initialMinutes * 60 {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
static const presets = [15, 25, 45];
|
||||
|
||||
final Future<void> Function(int minutes) _onCompleted;
|
||||
Timer? _ticker;
|
||||
DateTime? _endsAt;
|
||||
int _selectedMinutes;
|
||||
int _remainingSeconds;
|
||||
FocusTimerStatus _status = FocusTimerStatus.idle;
|
||||
bool _isCompleting = false;
|
||||
|
||||
int get selectedMinutes => _selectedMinutes;
|
||||
int get remainingSeconds => _remainingSeconds;
|
||||
FocusTimerStatus get status => _status;
|
||||
bool get isRunning => _status == FocusTimerStatus.running;
|
||||
bool get isPaused => _status == FocusTimerStatus.paused;
|
||||
bool get isCompleted => _status == FocusTimerStatus.completed;
|
||||
double get progress {
|
||||
final totalSeconds = _selectedMinutes * 60;
|
||||
return totalSeconds == 0
|
||||
? 0
|
||||
: (1 - (_remainingSeconds / totalSeconds)).clamp(0, 1);
|
||||
}
|
||||
|
||||
void selectMinutes(int minutes) {
|
||||
if (isRunning || !presets.contains(minutes)) {
|
||||
return;
|
||||
}
|
||||
_selectedMinutes = minutes;
|
||||
_remainingSeconds = minutes * 60;
|
||||
_status = FocusTimerStatus.idle;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void start() {
|
||||
if (isRunning || _isCompleting) {
|
||||
return;
|
||||
}
|
||||
if (isCompleted || _remainingSeconds <= 0) {
|
||||
_remainingSeconds = _selectedMinutes * 60;
|
||||
}
|
||||
_status = FocusTimerStatus.running;
|
||||
_endsAt = DateTime.now().add(Duration(seconds: _remainingSeconds));
|
||||
_startTicker();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
if (!isRunning) {
|
||||
return;
|
||||
}
|
||||
_refreshRemainingTime();
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_status = FocusTimerStatus.paused;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_remainingSeconds = _selectedMinutes * 60;
|
||||
_status = FocusTimerStatus.idle;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> completeNow() => _complete();
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && isRunning) {
|
||||
_refreshRemainingTime();
|
||||
if (_remainingSeconds > 0) {
|
||||
_startTicker();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
_ticker?.cancel();
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
_refreshRemainingTime();
|
||||
if (_remainingSeconds <= 0) {
|
||||
unawaited(_complete());
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _refreshRemainingTime() {
|
||||
final endsAt = _endsAt;
|
||||
if (endsAt == null) {
|
||||
return;
|
||||
}
|
||||
final milliseconds = endsAt.difference(DateTime.now()).inMilliseconds;
|
||||
_remainingSeconds = math.max(0, (milliseconds / 1000).ceil());
|
||||
}
|
||||
|
||||
Future<void> _complete() async {
|
||||
if (_isCompleting || isCompleted) {
|
||||
return;
|
||||
}
|
||||
_isCompleting = true;
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_remainingSeconds = 0;
|
||||
_status = FocusTimerStatus.completed;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _onCompleted(_selectedMinutes);
|
||||
} finally {
|
||||
_isCompleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user