checkpoint: focus seed 1.0.1 localized dev switch

This commit is contained in:
2026-06-11 23:35:49 +08:00
parent c51abdfb73
commit d06dc35ee6
72 changed files with 6155 additions and 1390 deletions
@@ -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();
}
}