首次远程推送

This commit is contained in:
2026-09-27 23:40:22 +08:00
parent 2e0bf3c784
commit 8afa47952a
70 changed files with 8603 additions and 4502 deletions
+87 -110
View File
@@ -4,33 +4,38 @@ import '../core/ambience.dart';
class AppTheme {
static const ink = Color(0xFF17211C);
static const leaf = Color(0xFF4D8B63);
static const mint = Color(0xFFA9E8B5);
static const ultramarine = Color(0xFF4E63D8);
static const coral = Color(0xFFEA725E);
static const violet = Color(0xFF7856D8);
static const porcelain = Color(0xFFF7F8F5);
static const forest = Color(0xFF08785B);
static const teal = Color(0xFF0A8179);
static const moss = Color(0xFF6C8F6B);
static const mint = Color(0xFFA8E6C1);
static const ultramarine = Color(0xFF2E6FDB);
static const sky = Color(0xFF42A5F5);
static const amber = Color(0xFFE3A321);
static const coral = Color(0xFFD9654F);
static const violet = Color(0xFF7257C8);
static const paper = Color(0xFFFAFBFF);
static const porcelain = Color(0xFFF4F7FC);
static ThemeData light(DayPhase phase) {
final surface = switch (phase) {
DayPhase.dawn => const Color(0xFFF3F8F7),
DayPhase.dawn => const Color(0xFFF3F8FC),
DayPhase.day => porcelain,
DayPhase.dusk => const Color(0xFFFBF5F2),
DayPhase.night => const Color(0xFFF3F5F3),
DayPhase.dusk => const Color(0xFFFBF6F4),
DayPhase.night => const Color(0xFFF2F5FA),
};
final scheme = ColorScheme.fromSeed(seedColor: leaf).copyWith(
primary: leaf,
final scheme = ColorScheme.fromSeed(seedColor: ultramarine).copyWith(
primary: ultramarine,
onPrimary: Colors.white,
secondary: ultramarine,
tertiary: coral,
tertiary: amber,
surface: surface,
onSurface: ink,
surfaceContainerLowest: Colors.white.withValues(alpha: 0.82),
surfaceContainerLow: Colors.white.withValues(alpha: 0.72),
surfaceContainer: const Color(0xFFF0F3EF),
surfaceContainerHigh: const Color(0xFFE9EEE9),
outline: const Color(0xFFB9C2BA),
outlineVariant: const Color(0xFFDDE3DD),
surfaceContainerLowest: const Color(0xFFFFFEFA),
surfaceContainerLow: const Color(0xFFEFF3FA),
surfaceContainer: const Color(0xFFE4EAF4),
surfaceContainerHigh: const Color(0xFFD6DEEB),
outline: const Color(0xFF9EA9B8),
outlineVariant: const Color(0xFFD5DEEA),
);
return _base(scheme, phase);
}
@@ -42,50 +47,32 @@ class AppTheme {
DayPhase.dusk => const Color(0xFF1A181A),
DayPhase.night => const Color(0xFF101614),
};
final scheme = ColorScheme.fromSeed(
seedColor: mint,
brightness: Brightness.dark,
).copyWith(
primary: mint,
onPrimary: ink,
secondary: const Color(0xFFB9C2FF),
tertiary: const Color(0xFFFFB4A8),
final scheme = ColorScheme.fromSeed(seedColor: mint, brightness: Brightness.dark).copyWith(
primary: const Color(0xFF8FBAFF),
onPrimary: const Color(0xFF071226),
secondary: const Color(0xFF8FBAFF),
tertiary: const Color(0xFFFFD782),
surface: surface,
onSurface: const Color(0xFFF1F5F1),
surfaceContainerLowest: const Color(0xFF0D1210),
surfaceContainerLow: const Color(0xFF171E1B),
surfaceContainer: const Color(0xFF1C2420),
surfaceContainerHigh: const Color(0xFF243029),
outline: const Color(0xFF7E8D83),
outlineVariant: const Color(0xFF344039),
surfaceContainerLow: const Color(0xFF151C24),
surfaceContainer: const Color(0xFF1B2430),
surfaceContainerHigh: const Color(0xFF253142),
outline: const Color(0xFF7F8EA4),
outlineVariant: const Color(0xFF344153),
);
return _base(scheme, phase);
}
static ThemeData _base(ColorScheme scheme, DayPhase phase) {
final base = ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
);
final base = ThemeData(useMaterial3: true, colorScheme: scheme, scaffoldBackgroundColor: scheme.surface);
final textTheme = base.textTheme.copyWith(
headlineSmall: base.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
height: 1.15,
),
titleLarge: base.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
height: 1.2,
),
titleMedium: base.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
height: 1.25,
),
headlineSmall: base.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700, height: 1.15),
titleLarge: base.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700, height: 1.2),
titleMedium: base.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, height: 1.25),
bodyLarge: base.textTheme.bodyLarge?.copyWith(height: 1.4),
bodyMedium: base.textTheme.bodyMedium?.copyWith(height: 1.4),
labelLarge: base.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
labelLarge: base.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
);
return base.copyWith(
textTheme: textTheme,
@@ -94,34 +81,22 @@ class AppTheme {
elevation: 0,
margin: EdgeInsets.zero,
color: scheme.surfaceContainerLowest,
shadowColor: ink.withValues(alpha: 0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: scheme.outlineVariant),
),
shadowColor: ink.withValues(alpha: 0.10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
navigationBarTheme: NavigationBarThemeData(
height: 68,
height: 72,
elevation: 0,
backgroundColor: scheme.surfaceContainerLowest,
indicatorColor: scheme.primary.withValues(alpha: 0.14),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
fontSize: 12,
fontWeight:
states.contains(WidgetState.selected)
? FontWeight.w700
: FontWeight.w500,
),
),
indicatorColor: scheme.primary.withValues(alpha: 0.13),
iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData(size: 23, color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant)),
labelTextStyle: WidgetStateProperty.resolveWith((states) => TextStyle(fontSize: 12, fontWeight: states.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500)),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(48, 48),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: const TextStyle(fontWeight: FontWeight.w700),
),
),
@@ -129,47 +104,24 @@ class AppTheme {
style: OutlinedButton.styleFrom(
minimumSize: const Size(48, 48),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 13,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.outlineVariant),
),
),
chipTheme: base.chipTheme.copyWith(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: BorderSide(color: scheme.outlineVariant),
),
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
space: 1,
thickness: 1,
),
dialogTheme: DialogTheme(
backgroundColor: scheme.surfaceContainerLowest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.22))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.22))),
),
chipTheme: base.chipTheme.copyWith(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.38))),
dividerTheme: DividerThemeData(color: scheme.outlineVariant.withValues(alpha: 0.64), space: 1, thickness: 1),
dialogTheme: DialogTheme(backgroundColor: scheme.surfaceContainerLowest, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surface,
modalBackgroundColor: scheme.surface,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
),
extensions: [FocusSeedVisuals.forPhase(phase, scheme.brightness)],
);
@@ -180,14 +132,26 @@ class AppTheme {
class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
const FocusSeedVisuals({
required this.ambientTint,
required this.brand,
required this.action,
required this.focus,
required this.habit,
required this.reflection,
required this.success,
required this.privacy,
required this.warning,
required this.shadow,
});
final Color ambientTint;
final Color brand;
final Color action;
final Color focus;
final Color habit;
final Color reflection;
final Color success;
final Color privacy;
final Color warning;
final Color shadow;
static FocusSeedVisuals of(BuildContext context) {
@@ -203,24 +167,31 @@ class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
DayPhase.night => const Color(0xFF9A89D8),
};
return FocusSeedVisuals(
ambientTint: ambientTint.withValues(alpha: isDark ? 0.18 : 0.22),
reflection: isDark ? const Color(0xFFBDAAFF) : AppTheme.violet,
success: isDark ? AppTheme.mint : AppTheme.leaf,
ambientTint: ambientTint.withValues(alpha: isDark ? 0.14 : 0.18),
brand: isDark ? const Color(0xFFF1F5F1) : AppTheme.ink,
action: isDark ? const Color(0xFFFFB09D) : AppTheme.coral,
focus: isDark ? const Color(0xFF8FBAFF) : AppTheme.ultramarine,
habit: isDark ? const Color(0xFFFFD782) : AppTheme.amber,
reflection: isDark ? const Color(0xFFC7B8FF) : AppTheme.violet,
success: isDark ? AppTheme.mint : AppTheme.forest,
privacy: isDark ? const Color(0xFF8DDFC1) : AppTheme.forest,
warning: isDark ? const Color(0xFFFFB4A8) : AppTheme.coral,
shadow: Colors.black.withValues(alpha: isDark ? 0.32 : 0.10),
);
}
@override
FocusSeedVisuals copyWith({
Color? ambientTint,
Color? reflection,
Color? success,
Color? shadow,
}) {
FocusSeedVisuals copyWith({Color? ambientTint, Color? brand, Color? action, Color? focus, Color? habit, Color? reflection, Color? success, Color? privacy, Color? warning, Color? shadow}) {
return FocusSeedVisuals(
ambientTint: ambientTint ?? this.ambientTint,
brand: brand ?? this.brand,
action: action ?? this.action,
focus: focus ?? this.focus,
habit: habit ?? this.habit,
reflection: reflection ?? this.reflection,
success: success ?? this.success,
privacy: privacy ?? this.privacy,
warning: warning ?? this.warning,
shadow: shadow ?? this.shadow,
);
}
@@ -232,8 +203,14 @@ class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
}
return FocusSeedVisuals(
ambientTint: Color.lerp(ambientTint, other.ambientTint, t)!,
brand: Color.lerp(brand, other.brand, t)!,
action: Color.lerp(action, other.action, t)!,
focus: Color.lerp(focus, other.focus, t)!,
habit: Color.lerp(habit, other.habit, t)!,
reflection: Color.lerp(reflection, other.reflection, t)!,
success: Color.lerp(success, other.success, t)!,
privacy: Color.lerp(privacy, other.privacy, t)!,
warning: Color.lerp(warning, other.warning, t)!,
shadow: Color.lerp(shadow, other.shadow, t)!,
);
}
+64 -11
View File
@@ -7,10 +7,12 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import '../core/ambience.dart';
import '../data/app_preferences_store.dart';
import '../data/focus_seed_store.dart';
import '../features/onboarding/welcome_screen.dart';
import '../features/shell/focus_seed_home.dart';
import '../l10n/generated/app_localizations.dart';
import '../models/appearance_preference.dart';
import '../models/language_preference.dart';
import '../widgets/loading_view.dart';
import 'app_theme.dart';
@visibleForTesting
@@ -19,10 +21,16 @@ Locale focusSeedDefaultLocale({bool isRelease = kReleaseMode}) {
}
class FocusSeedApp extends StatefulWidget {
const FocusSeedApp({this.locale, this.focusSeedStore, super.key});
const FocusSeedApp({
this.locale,
this.focusSeedStore,
this.showWelcomeOnFirstRun = true,
super.key,
});
final Locale? locale;
final FocusSeedStore? focusSeedStore;
final bool showWelcomeOnFirstRun;
@override
State<FocusSeedApp> createState() => _FocusSeedAppState();
@@ -33,6 +41,10 @@ class _FocusSeedAppState extends State<FocusSeedApp>
final _preferencesStore = AppPreferencesStore();
AppearancePreference _appearance = AppearancePreference.system;
LanguagePreference _language = LanguagePreference.defaultLocale;
bool _onboardingComplete = true;
bool _landingSetupComplete = true;
bool _showLandingSetup = false;
bool _preferencesLoaded = false;
DayPhase _dayPhase = dayPhaseFor(DateTime.now());
Timer? _phaseTimer;
@@ -52,14 +64,25 @@ class _FocusSeedAppState extends State<FocusSeedApp>
}
Future<void> _loadPreferences() async {
final appearance = await _preferencesStore.loadAppearance();
final language = await _preferencesStore.loadLanguage();
final (appearance, language, firstRunProgress) =
await (
_preferencesStore.loadAppearance(),
_preferencesStore.loadLanguage(),
_preferencesStore.loadFirstRunProgress(),
).wait;
if (!mounted) {
return;
}
setState(() {
_appearance = appearance;
_language = language;
_onboardingComplete = firstRunProgress.onboardingComplete;
_landingSetupComplete = firstRunProgress.landingSetupComplete;
_showLandingSetup =
widget.showWelcomeOnFirstRun &&
firstRunProgress.onboardingComplete &&
!firstRunProgress.landingSetupComplete;
_preferencesLoaded = true;
});
}
@@ -98,6 +121,22 @@ class _FocusSeedAppState extends State<FocusSeedApp>
return widget.locale ?? _language.locale ?? focusSeedDefaultLocale();
}
Future<void> _completeOnboarding() async {
setState(() {
_onboardingComplete = true;
_showLandingSetup = true;
});
await _preferencesStore.saveOnboardingComplete();
}
Future<void> _completeLandingSetup() async {
setState(() {
_landingSetupComplete = true;
_showLandingSetup = false;
});
await _preferencesStore.saveLandingSetupComplete();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
@@ -115,14 +154,28 @@ class _FocusSeedAppState extends State<FocusSeedApp>
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: FocusSeedHome(
focusSeedStore: widget.focusSeedStore,
appearance: _appearance,
onAppearanceChanged: _setAppearance,
language: _language,
showLanguageTools: !kReleaseMode && widget.locale == null,
onLanguageChanged: _setLanguage,
),
home:
!_preferencesLoaded
? const Scaffold(body: SafeArea(child: LoadingView()))
: widget.showWelcomeOnFirstRun && !_onboardingComplete
? WelcomeScreen(
onStart: _completeOnboarding,
onSkip: _completeOnboarding,
)
: FocusSeedHome(
focusSeedStore: widget.focusSeedStore,
appearance: _appearance,
onAppearanceChanged: _setAppearance,
language: _language,
showLanguageTools: !kReleaseMode && widget.locale == null,
onLanguageChanged: _setLanguage,
showLandingSetupInitially:
widget.showWelcomeOnFirstRun &&
(!_landingSetupComplete || _showLandingSetup),
onLandingSetupComplete: () {
unawaited(_completeLandingSetup());
},
),
);
}
+70 -13
View File
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../core/date_keys.dart';
import '../core/focus_durations.dart';
import '../data/focus_seed_store.dart';
import '../models/app_state.dart';
import '../models/focus_session.dart';
@@ -55,12 +56,18 @@ class FocusSeedController extends ChangeNotifier {
Future<bool> setTodayAnchor(String value) async {
final current = _state;
final anchor = value.trim();
if (current == null || anchor.isEmpty) {
final title = value.trim();
if (current == null || title.isEmpty) {
return false;
}
_analytics.track('intention_saved');
return _commit(current.copyWith(anchor: anchor, anchorDateKey: todayKey()));
return _commit(
current.copyWith(
anchor: title,
anchorDateKey: todayKey(),
selectedActionId: '',
),
);
}
Future<bool> toggleHabit(Habit habit) async {
@@ -128,46 +135,86 @@ class FocusSeedController extends ChangeNotifier {
return _commit(current.copyWith(habits: habits));
}
Future<bool> completeFocusSession(int minutes) async {
Future<bool> completeFocusSession(int minutes, {int? actualSeconds}) async {
final current = _state;
if (current == null) {
return false;
}
final now = DateTime.now();
final today = todayKey();
final anchor = current.activeAnchor(today);
final session = FocusSession(
id: 'session-${now.microsecondsSinceEpoch}',
minutes: minutes,
actualSeconds: actualSeconds,
completedAt: now,
intention: anchor,
actionTitleSnapshot: anchor,
);
_analytics.track('focus_completed', {
'minutes': minutes,
'actualSeconds': session.actualSeconds,
});
return _commit(
current.copyWith(
anchor: anchor,
anchorDateKey: today,
sessions: [...current.sessions, session],
),
);
_analytics.track('focus_completed', {'minutes': minutes});
return _commit(current.copyWith(sessions: [...current.sessions, session]));
}
Future<bool> saveReflection(String value, Mood mood) async {
return saveReflectionForDate(todayKey(), value, mood);
}
Future<bool> saveReflectionForDate(
String dateKey,
String value,
Mood mood,
) async {
final current = _state;
final note = value.trim();
if (current == null || note.isEmpty) {
final trimmedNote = value.trim();
final targetDateKey = dateKey.trim();
if (current == null || targetDateKey.isEmpty || trimmedNote.isEmpty) {
return false;
}
final today = todayKey();
final now = DateTime.now();
final entry = ReflectionEntry(
id: 'reflection-${now.microsecondsSinceEpoch}',
dateKey: today,
note: note,
dateKey: targetDateKey,
note: trimmedNote,
mood: mood,
createdAt: now,
);
final reflections = [
...current.reflections.where((item) => item.dateKey != today),
...current.reflections.where((item) => item.dateKey != targetDateKey),
entry,
];
_analytics.track('reflection_saved');
return _commit(current.copyWith(reflections: reflections));
}
Future<bool> setDefaultFocusMinutes(int minutes) async {
final current = _state;
if (current == null || !isFocusDurationPreset(minutes)) {
return false;
}
if (current.defaultFocusMinutes == minutes) {
return true;
}
_analytics.track('default_focus_minutes_changed', {'minutes': minutes});
return _commit(current.copyWith(defaultFocusMinutes: minutes));
}
Future<bool> clearData() async {
return _commit(AppState.starter(defaultHabitTitles: _defaultHabitTitles));
final current = _state;
return _commit(
AppState.starter(defaultHabitTitles: _defaultHabitTitles).copyWith(
defaultFocusMinutes:
current?.defaultFocusMinutes ?? defaultFocusDurationMinutes,
),
);
}
Future<bool> clearToday() async {
@@ -190,12 +237,22 @@ class FocusSeedController extends ChangeNotifier {
final reflections = current.reflections
.where((reflection) => reflection.dateKey != today)
.toList(growable: false);
final actions = current.actions
.where((action) => action.dateKey != today)
.toList(growable: false);
final progressNotes = current.progressNotes
.where((note) => note.dateKey != today)
.toList(growable: false);
_analytics.track('today_cleared');
return _commit(
current.copyWith(
anchor: current.anchorDateKey == today ? '' : current.anchor,
selectedActionId:
current.anchorDateKey == today ? '' : current.selectedActionId,
habits: habits,
actions: actions,
sessions: sessions,
progressNotes: progressNotes,
reflections: reflections,
),
);
+147 -5
View File
@@ -3,21 +3,30 @@ import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import '../core/focus_durations.dart';
import '../data/focus_timer_session_store.dart';
enum FocusTimerStatus { idle, running, paused, completed }
typedef FocusTimerCompleted =
Future<bool> Function(int minutes, int actualSeconds);
class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
FocusTimerController({
required Future<void> Function(int minutes) onCompleted,
int initialMinutes = 25,
required FocusTimerCompleted onCompleted,
FocusTimerSessionStore? sessionStore,
int initialMinutes = defaultFocusDurationMinutes,
}) : _onCompleted = onCompleted,
_sessionStore = sessionStore,
_selectedMinutes = initialMinutes,
_remainingSeconds = initialMinutes * 60 {
WidgetsBinding.instance.addObserver(this);
}
static const presets = [15, 25, 45];
static const presets = focusDurationPresets;
final Future<void> Function(int minutes) _onCompleted;
final FocusTimerCompleted _onCompleted;
final FocusTimerSessionStore? _sessionStore;
Timer? _ticker;
DateTime? _endsAt;
int _selectedMinutes;
@@ -27,6 +36,8 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
int get selectedMinutes => _selectedMinutes;
int get remainingSeconds => _remainingSeconds;
int get elapsedSeconds =>
math.max(0, (_selectedMinutes * 60) - _remainingSeconds);
FocusTimerStatus get status => _status;
bool get isRunning => _status == FocusTimerStatus.running;
bool get isPaused => _status == FocusTimerStatus.paused;
@@ -38,6 +49,53 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
: (1 - (_remainingSeconds / totalSeconds)).clamp(0, 1);
}
Future<bool> restoreSavedSession() async {
final sessionStore = _sessionStore;
if (sessionStore == null) {
return false;
}
final snapshot = await sessionStore.load();
if (snapshot == null) {
return false;
}
if (!presets.contains(snapshot.selectedMinutes)) {
await sessionStore.clear();
return false;
}
_selectedMinutes = snapshot.selectedMinutes;
final totalSeconds = _selectedMinutes * 60;
_remainingSeconds =
snapshot.remainingSeconds.clamp(0, totalSeconds).toInt();
_ticker?.cancel();
_endsAt = null;
switch (snapshot.status) {
case FocusTimerSnapshotStatus.running:
_status = FocusTimerStatus.running;
_endsAt =
snapshot.endsAt ??
DateTime.now().add(Duration(seconds: _remainingSeconds));
_refreshRemainingTime();
if (_remainingSeconds <= 0) {
unawaited(_complete());
} else {
_startTicker();
}
break;
case FocusTimerSnapshotStatus.paused:
_status = FocusTimerStatus.paused;
break;
case FocusTimerSnapshotStatus.completed:
_remainingSeconds = 0;
_status = FocusTimerStatus.completed;
unawaited(_retryCompletedSnapshot(snapshot));
break;
}
notifyListeners();
return true;
}
void selectMinutes(int minutes) {
if (isRunning || !presets.contains(minutes)) {
return;
@@ -58,6 +116,7 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
_status = FocusTimerStatus.running;
_endsAt = DateTime.now().add(Duration(seconds: _remainingSeconds));
_startTicker();
unawaited(_persistActiveSession());
notifyListeners();
}
@@ -69,6 +128,7 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
_ticker?.cancel();
_endsAt = null;
_status = FocusTimerStatus.paused;
unawaited(_persistActiveSession());
notifyListeners();
}
@@ -77,6 +137,7 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
_endsAt = null;
_remainingSeconds = _selectedMinutes * 60;
_status = FocusTimerStatus.idle;
unawaited(_clearSavedSession());
notifyListeners();
}
@@ -118,19 +179,100 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
if (_isCompleting || isCompleted) {
return;
}
if (isRunning) {
_refreshRemainingTime();
}
final totalSeconds = _selectedMinutes * 60;
final actualSeconds =
_remainingSeconds <= 0
? totalSeconds
: math.max(1, totalSeconds - _remainingSeconds);
_isCompleting = true;
_ticker?.cancel();
_endsAt = null;
_remainingSeconds = 0;
_status = FocusTimerStatus.completed;
unawaited(_persistCompletedSession(actualSeconds));
notifyListeners();
try {
await _onCompleted(_selectedMinutes);
final saved = await _onCompleted(_selectedMinutes, actualSeconds);
if (saved) {
await _clearSavedSession();
}
} finally {
_isCompleting = false;
}
}
Future<void> _retryCompletedSnapshot(
FocusTimerSessionSnapshot snapshot,
) async {
if (_isCompleting || snapshot.actualSeconds == null) {
return;
}
_isCompleting = true;
try {
final saved = await _onCompleted(
snapshot.selectedMinutes,
snapshot.actualSeconds!,
);
if (saved) {
await _clearSavedSession();
}
} finally {
_isCompleting = false;
}
}
Future<void> _persistActiveSession() async {
final sessionStore = _sessionStore;
if (sessionStore == null) {
return;
}
if (isRunning) {
await sessionStore.save(
FocusTimerSessionSnapshot(
status: FocusTimerSnapshotStatus.running,
selectedMinutes: _selectedMinutes,
remainingSeconds: _remainingSeconds,
endsAt: _endsAt,
updatedAt: DateTime.now(),
),
);
return;
}
if (isPaused) {
await sessionStore.save(
FocusTimerSessionSnapshot(
status: FocusTimerSnapshotStatus.paused,
selectedMinutes: _selectedMinutes,
remainingSeconds: _remainingSeconds,
updatedAt: DateTime.now(),
),
);
}
}
Future<void> _persistCompletedSession(int actualSeconds) async {
final sessionStore = _sessionStore;
if (sessionStore == null) {
return;
}
await sessionStore.save(
FocusTimerSessionSnapshot(
status: FocusTimerSnapshotStatus.completed,
selectedMinutes: _selectedMinutes,
remainingSeconds: 0,
actualSeconds: actualSeconds,
updatedAt: DateTime.now(),
),
);
}
Future<void> _clearSavedSession() async {
await _sessionStore?.clear();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
+1 -1
View File
@@ -22,7 +22,7 @@ List<String> lastSevenDateKeys() {
int minutesForDate(List<FocusSession> sessions, String key) {
return sessions
.where((session) => dateKey(session.completedAt) == key)
.fold<int>(0, (total, session) => total + session.minutes);
.fold<int>(0, (total, session) => total + session.actualMinutes);
}
int currentStreak(
+43
View File
@@ -3,9 +3,22 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/appearance_preference.dart';
import '../models/language_preference.dart';
class FirstRunProgress {
const FirstRunProgress({
required this.onboardingComplete,
required this.landingSetupComplete,
});
final bool onboardingComplete;
final bool landingSetupComplete;
}
class AppPreferencesStore {
static const _appearanceKey = 'focus_seed_appearance';
static const _languageKey = 'focus_seed_language';
static const _legacyWelcomeCompleteKey = 'focus_seed_welcome_complete';
static const _onboardingCompleteKey = 'focus_seed_onboarding_complete';
static const _landingSetupCompleteKey = 'focus_seed_landing_setup_complete';
Future<AppearancePreference> loadAppearance() async {
final preferences = await SharedPreferences.getInstance();
@@ -26,4 +39,34 @@ class AppPreferencesStore {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(_languageKey, language.name);
}
Future<FirstRunProgress> loadFirstRunProgress() async {
final preferences = await SharedPreferences.getInstance();
final legacyComplete =
preferences.getBool(_legacyWelcomeCompleteKey) ?? false;
final landingSetupComplete =
preferences.getBool(_landingSetupCompleteKey) ?? legacyComplete;
final onboardingComplete =
preferences.getBool(_onboardingCompleteKey) ??
(legacyComplete || landingSetupComplete);
return FirstRunProgress(
onboardingComplete: onboardingComplete,
landingSetupComplete: landingSetupComplete,
);
}
Future<void> saveOnboardingComplete() async {
final preferences = await SharedPreferences.getInstance();
await preferences.setBool(_onboardingCompleteKey, true);
if (!preferences.containsKey(_landingSetupCompleteKey)) {
await preferences.setBool(_landingSetupCompleteKey, false);
}
}
Future<void> saveLandingSetupComplete() async {
final preferences = await SharedPreferences.getInstance();
await preferences.setBool(_onboardingCompleteKey, true);
await preferences.setBool(_landingSetupCompleteKey, true);
await preferences.setBool(_legacyWelcomeCompleteKey, true);
}
}
+187 -6
View File
@@ -4,10 +4,13 @@ import 'package:drift/drift.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/date_keys.dart' as date_keys;
import '../core/focus_durations.dart';
import '../models/app_state.dart';
import '../models/daily_action.dart';
import '../models/focus_session.dart';
import '../models/habit.dart';
import '../models/mood.dart';
import '../models/progress_note.dart';
import '../models/reflection_entry.dart';
import 'local/focus_seed_database.dart';
@@ -17,6 +20,7 @@ class FocusSeedStore {
static const _legacyStorageKey = 'focus_seed_state_v1';
static const _legacyMigrationKey = 'focus_seed_state_v1_migrated_to_sqlite';
static const _defaultFocusMinutesKey = 'default_focus_minutes';
final FocusSeedDatabase _database;
@@ -35,25 +39,56 @@ class FocusSeedStore {
Future<void> save(AppState state) async {
await _database.transaction(() async {
await _database.delete(_database.habitLogRows).go();
await _database.delete(_database.progressNoteRows).go();
await _database.delete(_database.reflectionRows).go();
await _database.delete(_database.focusSessionRows).go();
await _database.delete(_database.recentLabelRows).go();
await _database.delete(_database.dailyActionRows).go();
await _database.delete(_database.habitRows).go();
await _database.delete(_database.dailyIntentionRows).go();
if (state.anchor.trim().isNotEmpty) {
await _database
.into(_database.appSettingRows)
.insertOnConflictUpdate(
AppSettingRowsCompanion.insert(
key: _defaultFocusMinutesKey,
value: state.defaultFocusMinutes.toString(),
),
);
final anchorText = state.anchor.trim();
if (anchorText.isNotEmpty) {
final now = DateTime.now();
await _database
.into(_database.dailyIntentionRows)
.insert(
DailyIntentionRowsCompanion.insert(
dateKey: state.anchorDateKey,
intentionText: state.anchor,
intentionText: anchorText,
createdAt: now,
updatedAt: now,
),
);
}
for (final action in state.actions) {
await _database
.into(_database.dailyActionRows)
.insert(_dailyActionCompanion(action));
}
for (final label in state.recentLabels) {
await _database
.into(_database.recentLabelRows)
.insert(
RecentLabelRowsCompanion.insert(
label: label,
lastUsedAt: DateTime.now(),
useCount: 1,
),
);
}
for (final (index, habit) in state.habits.indexed) {
await _database
.into(_database.habitRows)
@@ -85,6 +120,12 @@ class FocusSeedStore {
.insert(_focusSessionCompanion(session));
}
for (final note in state.progressNotes) {
await _database
.into(_database.progressNoteRows)
.insert(_progressNoteCompanion(note));
}
for (final reflection in state.reflections) {
await _database
.into(_database.reflectionRows)
@@ -120,6 +161,12 @@ class FocusSeedStore {
..limit(1))
.get();
final latestIntention = intentionRows.firstOrNull;
final dailyActionRows =
await (_database.select(_database.dailyActionRows)..orderBy([
(row) => OrderingTerm.asc(row.dateKey),
(row) => OrderingTerm.asc(row.orderIndex),
(row) => OrderingTerm.asc(row.createdAt),
])).get();
final habitRows =
await (_database.select(_database.habitRows)
..where((row) => row.archivedAt.isNull())
@@ -134,18 +181,36 @@ class FocusSeedStore {
final focusSessionRows =
await (_database.select(_database.focusSessionRows)
..orderBy([(row) => OrderingTerm.asc(row.endedAt)])).get();
final progressNoteRows =
await (_database.select(_database.progressNoteRows)
..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get();
final reflectionRows =
await (_database.select(_database.reflectionRows)
..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get();
final recentLabelRows =
await (_database.select(_database.recentLabelRows)..orderBy([
(row) => OrderingTerm.desc(row.lastUsedAt),
(row) => OrderingTerm.desc(row.useCount),
])).get();
final defaultFocusMinutes = await _readDefaultFocusMinutes();
final completedDatesByHabit = <String, Set<String>>{};
for (final log in habitLogRows) {
completedDatesByHabit.putIfAbsent(log.habitId, () => {}).add(log.dateKey);
}
final actions = [
for (final action in dailyActionRows) _dailyActionFromRow(action),
];
final progressNotes = [
for (final note in progressNoteRows) _progressNoteFromRow(note),
];
final anchorDateKey = latestIntention?.dateKey ?? date_keys.todayKey();
final anchor = latestIntention?.intentionText ?? '';
return AppState(
anchor: latestIntention?.intentionText ?? '',
anchorDateKey: latestIntention?.dateKey ?? date_keys.todayKey(),
anchor: anchor,
anchorDateKey: anchorDateKey,
habits: [
for (final habit in habitRows)
Habit(
@@ -160,7 +225,12 @@ class FocusSeedStore {
FocusSession(
id: session.id,
minutes: session.plannedMinutes,
actualSeconds: session.actualSeconds,
completedAt: session.endedAt,
intention: session.intentionText ?? '',
actionId: session.actionId,
actionTitleSnapshot:
session.actionTitleSnapshot ?? session.intentionText,
),
],
reflections: [
@@ -176,9 +246,28 @@ class FocusSeedStore {
createdAt: reflection.createdAt,
),
],
actions: actions,
progressNotes: progressNotes,
recentLabels: [
for (final label in recentLabelRows)
if (label.label.trim().isNotEmpty) label.label.trim(),
],
defaultFocusMinutes: defaultFocusMinutes,
);
}
Future<int> _readDefaultFocusMinutes() async {
final row =
await (_database.select(_database.appSettingRows)..where(
(row) => row.key.equals(_defaultFocusMinutesKey),
)).getSingleOrNull();
final value = int.tryParse(row?.value ?? '');
if (value == null || !isFocusDurationPreset(value)) {
return defaultFocusDurationMinutes;
}
return value;
}
Future<void> _migrateLegacyJsonIfNeeded() async {
final preferences = await SharedPreferences.getInstance();
if (preferences.getBool(_legacyMigrationKey) ?? false) {
@@ -206,6 +295,12 @@ class FocusSeedStore {
}
Future<bool> _hasDatabaseContent() async {
final action =
await (_database.select(_database.dailyActionRows)
..limit(1)).getSingleOrNull();
if (action != null) {
return true;
}
final intention =
await (_database.select(_database.dailyIntentionRows)
..limit(1)).getSingleOrNull();
@@ -224,20 +319,106 @@ class FocusSeedStore {
if (session != null) {
return true;
}
final progress =
await (_database.select(_database.progressNoteRows)
..limit(1)).getSingleOrNull();
if (progress != null) {
return true;
}
final reflection =
await (_database.select(_database.reflectionRows)
..limit(1)).getSingleOrNull();
return reflection != null;
}
DailyAction _dailyActionFromRow(DailyActionRow row) {
return DailyAction(
id: row.id,
dateKey: row.dateKey,
title: row.title,
status: dailyActionStatusFromName(row.status),
orderIndex: row.orderIndex,
labels: _decodeLabels(row.labelsJson),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
archivedAt: row.archivedAt,
source: row.source,
);
}
ProgressNote _progressNoteFromRow(ProgressNoteRow row) {
return ProgressNote(
id: row.id,
dateKey: row.dateKey,
actionId: row.actionId,
sessionId: row.sessionId,
note: row.note,
outcome: progressOutcomeFromName(row.outcome),
labels: _decodeLabels(row.labelsJson),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
);
}
DailyActionRowsCompanion _dailyActionCompanion(DailyAction action) {
return DailyActionRowsCompanion.insert(
id: action.id,
dateKey: action.dateKey,
title: action.title,
status: action.status.name,
orderIndex: action.orderIndex,
labelsJson: jsonEncode(action.labels),
createdAt: action.createdAt,
updatedAt: action.updatedAt,
archivedAt: Value(action.archivedAt),
source: action.source,
);
}
ProgressNoteRowsCompanion _progressNoteCompanion(ProgressNote note) {
return ProgressNoteRowsCompanion.insert(
id: note.id,
dateKey: note.dateKey,
actionId: Value(note.actionId),
sessionId: Value(note.sessionId),
note: note.note,
outcome: Value(note.outcome?.name),
labelsJson: jsonEncode(note.labels),
createdAt: note.createdAt,
updatedAt: note.updatedAt,
);
}
List<String> _decodeLabels(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List<Object?>) {
return const [];
}
return [
for (final item in decoded)
if (item is String && item.trim().isNotEmpty) item.trim(),
];
} on Object {
return const [];
}
}
FocusSessionRowsCompanion _focusSessionCompanion(FocusSession session) {
final completedAt = session.completedAt;
return FocusSessionRowsCompanion.insert(
id: session.id,
dateKey: date_keys.dateKey(completedAt),
actionId: Value(session.actionId),
actionTitleSnapshot: Value(
session.actionTitle.trim().isEmpty ? null : session.actionTitle,
),
intentionText: Value(
session.actionTitle.trim().isEmpty ? null : session.actionTitle,
),
plannedMinutes: session.minutes,
actualSeconds: session.minutes * 60,
startedAt: completedAt.subtract(Duration(minutes: session.minutes)),
actualSeconds: session.actualSeconds,
startedAt: completedAt.subtract(Duration(seconds: session.actualSeconds)),
endedAt: completedAt,
status: 'completed',
);
+87 -1
View File
@@ -26,6 +26,10 @@ class FocusSessionRows extends Table {
TextColumn get id => text()();
TextColumn get dateKey => text()();
TextColumn get actionId => text().nullable().named('action_id')();
TextColumn get actionTitleSnapshot =>
text().nullable().named('action_title_snapshot')();
TextColumn get intentionText => text().nullable().named('intention_text')();
IntColumn get plannedMinutes => integer()();
IntColumn get actualSeconds => integer()();
DateTimeColumn get startedAt => dateTime()();
@@ -36,6 +40,55 @@ class FocusSessionRows extends Table {
Set<Column<Object>> get primaryKey => {id};
}
class DailyActionRows extends Table {
@override
String get tableName => 'daily_actions';
TextColumn get id => text()();
TextColumn get dateKey => text()();
TextColumn get title => text()();
TextColumn get status => text()();
IntColumn get orderIndex => integer().named('order_index')();
TextColumn get labelsJson => text().named('labels_json')();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
DateTimeColumn get archivedAt => dateTime().nullable()();
TextColumn get source => text()();
@override
Set<Column<Object>> get primaryKey => {id};
}
class ProgressNoteRows extends Table {
@override
String get tableName => 'progress_notes';
TextColumn get id => text()();
TextColumn get dateKey => text()();
TextColumn get actionId => text().nullable().named('action_id')();
TextColumn get sessionId => text().nullable().named('session_id')();
TextColumn get note => text()();
TextColumn get outcome => text().nullable()();
TextColumn get labelsJson => text().named('labels_json')();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column<Object>> get primaryKey => {id};
}
class RecentLabelRows extends Table {
@override
String get tableName => 'recent_labels';
TextColumn get label => text()();
DateTimeColumn get lastUsedAt => dateTime()();
IntColumn get useCount => integer()();
@override
Set<Column<Object>> get primaryKey => {label};
}
class HabitRows extends Table {
@override
String get tableName => 'habits';
@@ -92,7 +145,10 @@ class AppSettingRows extends Table {
@DriftDatabase(
tables: [
DailyIntentionRows,
DailyActionRows,
FocusSessionRows,
ProgressNoteRows,
RecentLabelRows,
HabitRows,
HabitLogRows,
ReflectionRows,
@@ -104,13 +160,43 @@ class FocusSeedDatabase extends _$FocusSeedDatabase {
: super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 5;
@override
MigrationStrategy get migration {
return MigrationStrategy(
onUpgrade: (migrator, from, to) async {
if (from < 3) {
await customStatement('DROP TABLE IF EXISTS focus_plans');
await customStatement('DROP TABLE IF EXISTS preference_tags');
}
if (from < 4) {
await customStatement(
'ALTER TABLE focus_sessions ADD COLUMN intention_text TEXT NULL',
);
}
if (from < 5) {
await migrator.createTable(dailyActionRows);
await migrator.createTable(progressNoteRows);
await migrator.createTable(recentLabelRows);
await migrator.addColumn(focusSessionRows, focusSessionRows.actionId);
await migrator.addColumn(
focusSessionRows,
focusSessionRows.actionTitleSnapshot,
);
}
},
);
}
Future<void> clearProductData() async {
await transaction(() async {
await delete(habitLogRows).go();
await delete(progressNoteRows).go();
await delete(reflectionRows).go();
await delete(focusSessionRows).go();
await delete(recentLabelRows).go();
await delete(dailyActionRows).go();
await delete(habitRows).go();
await delete(dailyIntentionRows).go();
});
File diff suppressed because it is too large Load Diff
+65 -101
View File
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import '../../app/app_theme.dart';
import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart';
import '../../l10n/l10n.dart';
import '../../widgets/app_bottom_sheet.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/loading_view.dart';
import '../today/widgets/compact_habit_row.dart';
Future<void> showHabitManagerSheet(
@@ -21,89 +25,63 @@ Future<void> showHabitManagerSheet(
animation: controller,
builder: (context, _) {
final state = controller.state;
if (state == null) {
return const Center(child: CircularProgressIndicator());
}
final l10n = context.l10n;
final visuals = FocusSeedVisuals.of(context);
if (state == null) {
return AppBottomSheetScaffold(
title: l10n.habitsTitle,
subtitle: l10n.habitsSubtitle,
icon: Icons.add_task_rounded,
accent: visuals.habit,
expand: true,
child: const LoadingView(),
);
}
final today = todayKey();
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 12, 12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.habitsTitle,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
l10n.habitsSubtitle,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton.filledTonal(
tooltip: l10n.addHabit,
onPressed:
return AppBottomSheetScaffold(
title: l10n.habitsTitle,
subtitle: l10n.habitsSubtitle,
icon: Icons.add_task_rounded,
accent: visuals.habit,
expand: true,
trailing: IconButton.filledTonal(
tooltip: l10n.addHabit,
onPressed:
() => showHabitDialog(context, controller.addHabit),
icon: const Icon(Icons.add_rounded),
),
child:
state.habits.isEmpty
? _EmptyHabits(
onAdd:
() =>
showHabitDialog(context, controller.addHabit),
icon: const Icon(Icons.add_rounded),
),
],
),
),
const Divider(),
Expanded(
child:
state.habits.isEmpty
? _EmptyHabits(
onAdd:
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(0, 2, 0, 24),
itemCount: state.habits.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final habit = state.habits[index];
return CompactHabitRow(
habit: habit,
dateKey: today,
onToggle: () => controller.toggleHabit(habit),
onEdit:
() => showHabitDialog(
context,
controller.addHabit,
(value) =>
controller.renameHabit(habit, value),
initialValue: habit.title,
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
itemCount: state.habits.length,
separatorBuilder: (_, _) => const Divider(),
itemBuilder: (context, index) {
final habit = state.habits[index];
return CompactHabitRow(
habit: habit,
dateKey: today,
onToggle: () => controller.toggleHabit(habit),
onEdit:
() => showHabitDialog(
context,
(value) => controller.renameHabit(
habit,
value,
),
initialValue: habit.title,
),
onDelete:
() => confirmDeleteHabit(
context,
() => controller.deleteHabit(habit),
),
);
},
),
),
],
onDelete:
() => confirmDeleteHabit(
context,
() => controller.deleteHabit(habit),
),
);
},
),
);
},
),
@@ -118,31 +96,17 @@ class _EmptyHabits extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.eco_outlined,
size: 40,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 12),
Text(
context.l10n.emptyHabitsTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add_rounded),
label: Text(context.l10n.addHabit),
),
],
),
),
final l10n = context.l10n;
return AppEmptyState(
title: l10n.emptyHabitsTitle,
body: l10n.emptyHabitPrompt,
icon: Icons.add_task_rounded,
actionLabel: l10n.addHabit,
actionIcon: Icons.add_rounded,
onAction: onAdd,
accent: FocusSeedVisuals.of(context).habit,
illustrationHeight: 86,
padding: const EdgeInsets.all(28),
);
}
}
@@ -1,411 +0,0 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../l10n/l10n.dart';
import '../../models/focus_session.dart';
import '../../models/reflection_entry.dart';
import '../../widgets/seed_illustration.dart';
import '../../widgets/surface_panel.dart';
enum _HistoryView { focus, notes }
class HistoryScreen extends StatefulWidget {
const HistoryScreen({required this.controller, super.key});
final FocusSeedController controller;
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
_HistoryView _view = _HistoryView.focus;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(title: Text(l10n.historyTitle)),
body: SafeArea(
top: false,
child: AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final state = widget.controller.state!;
final focusSessions = state.sessions.reversed.toList();
final reflections = state.reflections.reversed.toList();
return ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
_HistorySwitch(
value: _view,
onChanged: (value) => setState(() => _view = value),
),
const SizedBox(height: 10),
AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child:
_view == _HistoryView.focus
? _FocusHistoryList(
key: const ValueKey('focus-history'),
sessions: focusSessions,
onStartToday: () => Navigator.of(context).pop(),
)
: _ReflectionHistoryList(
key: const ValueKey('reflection-history'),
reflections: reflections,
onStartToday: () => Navigator.of(context).pop(),
),
),
],
);
},
),
),
);
}
}
class _HistorySwitch extends StatelessWidget {
const _HistorySwitch({required this.value, required this.onChanged});
final _HistoryView value;
final ValueChanged<_HistoryView> onChanged;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLow.withValues(alpha: 0.70),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: scheme.outlineVariant.withValues(alpha: 0.70),
),
),
child: Padding(
padding: const EdgeInsets.all(5),
child: Row(
children: [
Expanded(
child: _HistorySwitchItem(
selected: value == _HistoryView.focus,
icon: Icons.timer_outlined,
label: l10n.focusHistory,
onTap: () => onChanged(_HistoryView.focus),
),
),
const SizedBox(width: 6),
Expanded(
child: _HistorySwitchItem(
selected: value == _HistoryView.notes,
icon: Icons.edit_note_rounded,
label: l10n.notesHistory,
onTap: () => onChanged(_HistoryView.notes),
),
),
],
),
),
);
}
}
class _HistorySwitchItem extends StatelessWidget {
const _HistorySwitchItem({
required this.selected,
required this.icon,
required this.label,
required this.onTap,
});
final bool selected;
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color:
selected
? scheme.primary.withValues(alpha: 0.16)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
boxShadow:
selected
? [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.16),
blurRadius: 12,
offset: const Offset(0, 6),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 19,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color:
selected ? scheme.onSurface : scheme.onSurfaceVariant,
fontWeight: selected ? FontWeight.w900 : FontWeight.w700,
),
),
),
],
),
),
),
);
}
}
class _FocusHistoryList extends StatelessWidget {
const _FocusHistoryList({
required this.sessions,
required this.onStartToday,
super.key,
});
final List<FocusSession> sessions;
final VoidCallback onStartToday;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
if (sessions.isEmpty) {
return _EmptyHistory(
title: l10n.emptyFocusHistoryTitle,
body: l10n.emptyFocusHistoryBody,
tone: SeedIllustrationTone.archive,
onStartToday: onStartToday,
);
}
return Column(
children: [
for (var index = 0; index < sessions.length; index++) ...[
if (index > 0) const SizedBox(height: 8),
_HistoryTimelineTile(
leading: Icons.timer_outlined,
title: l10n.focusMinutes(sessions[index].minutes),
subtitle: localizedReadableDate(
context,
sessions[index].completedAt,
),
trailing: localizedTime(context, sessions[index].completedAt),
),
],
],
);
}
}
class _ReflectionHistoryList extends StatelessWidget {
const _ReflectionHistoryList({
required this.reflections,
required this.onStartToday,
super.key,
});
final List<ReflectionEntry> reflections;
final VoidCallback onStartToday;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
if (reflections.isEmpty) {
return _EmptyHistory(
title: l10n.emptyNotesHistoryTitle,
body: l10n.emptyNotesHistoryBody,
tone: SeedIllustrationTone.data,
onStartToday: onStartToday,
);
}
return Column(
children: [
for (var index = 0; index < reflections.length; index++) ...[
if (index > 0) const SizedBox(height: 8),
_HistoryTimelineTile(
leading: reflections[index].mood.icon,
title: reflections[index].note,
subtitle: reflections[index].dateKey,
),
],
],
);
}
}
class _EmptyHistory extends StatelessWidget {
const _EmptyHistory({
required this.title,
required this.body,
required this.tone,
required this.onStartToday,
});
final String title;
final String body;
final SeedIllustrationTone tone;
final VoidCallback onStartToday;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final minHeight = MediaQuery.sizeOf(context).height * 0.62;
return SizedBox(
height: minHeight.clamp(430, 620).toDouble(),
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.topCenter,
radius: 1.1,
colors: [
scheme.primary.withValues(alpha: 0.16),
scheme.surfaceContainerLowest.withValues(alpha: 0.76),
Colors.transparent,
],
),
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 26, 18, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SeedIllustration(size: 142, tone: tone, hero: true),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 310),
child: Text(
body,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: onStartToday,
icon: const Icon(Icons.arrow_back_rounded),
label: Text(context.l10n.navToday),
),
],
),
),
),
),
);
}
}
class _HistoryTimelineTile extends StatelessWidget {
const _HistoryTimelineTile({
required this.leading,
required this.title,
required this.subtitle,
this.trailing,
});
final IconData leading;
final String title;
final String subtitle;
final String? trailing;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return SurfacePanel(
padding: const EdgeInsets.fromLTRB(13, 12, 14, 12),
borderColor: scheme.outlineVariant.withValues(alpha: 0.60),
child: Row(
children: [
DecoratedBox(
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: SizedBox.square(
dimension: 42,
child: Icon(leading, color: scheme.primary, size: 21),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 3),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
if (trailing != null) ...[
const SizedBox(width: 10),
Text(
trailing!,
style: theme.textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
fontWeight: FontWeight.w800,
),
),
],
],
),
);
}
}
@@ -1,238 +0,0 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart';
import '../../l10n/l10n.dart';
import '../../models/journey_stats.dart';
import '../../models/today_progress.dart';
import '../../widgets/focus_seed_header.dart';
import '../../widgets/seed_illustration.dart';
import '../../widgets/surface_panel.dart';
import '../reflection/reflection_sheet.dart';
import 'history_screen.dart';
import 'widgets/growth_week_strip.dart';
import 'widgets/journey_overview_panel.dart';
import 'widgets/journey_stats_panel.dart';
class JourneyScreen extends StatelessWidget {
const JourneyScreen({required this.controller, super.key});
final FocusSeedController controller;
@override
Widget build(BuildContext context) {
final state = controller.state!;
final l10n = context.l10n;
final now = DateTime.now();
final progress = TodayProgress.fromState(state, todayKey());
final stats = JourneyStats.fromState(state);
final recentSessions = state.sessions.reversed.take(5).toList();
final recentReflections = state.reflections.reversed.take(3).toList();
return Column(
children: [
FocusSeedHeader(
title: l10n.journeyTitle,
subtitle: localizedReadableDate(context, now),
isSaving: controller.isSaving,
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 32),
children: [
JourneyOverviewPanel(
progress: progress,
onReflect: () => showReflectionSheet(context, controller),
),
const SizedBox(height: 12),
JourneyStatsPanel(stats: stats),
const SizedBox(height: 12),
GrowthWeekStrip(state: state),
const SizedBox(height: 12),
_HistoryGateway(
onTap:
() => Navigator.of(context).push(
MaterialPageRoute<void>(
builder:
(context) => HistoryScreen(controller: controller),
),
),
),
if (recentSessions.isNotEmpty) ...[
const SizedBox(height: 18),
_SectionTitle(
title: l10n.recentFocus,
detail: l10n.sessionsTotal(state.sessions.length),
),
const SizedBox(height: 8),
SurfacePanel(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 4,
),
child: Column(
children: [
for (
var index = 0;
index < recentSessions.length;
index++
) ...[
if (index > 0) const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.timer_outlined),
title: Text(
l10n.focusMinutes(recentSessions[index].minutes),
),
subtitle: Text(
localizedReadableDate(
context,
recentSessions[index].completedAt,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
localizedTime(
context,
recentSessions[index].completedAt,
),
),
),
],
],
),
),
],
if (recentReflections.isNotEmpty) ...[
const SizedBox(height: 18),
_SectionTitle(
title: l10n.recentReflections,
detail: l10n.recentReflectionsDetail,
),
const SizedBox(height: 8),
SurfacePanel(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 4,
),
child: Column(
children: [
for (
var index = 0;
index < recentReflections.length;
index++
) ...[
if (index > 0) const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(recentReflections[index].mood.icon),
title: Text(
recentReflections[index].note,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(recentReflections[index].dateKey),
),
],
],
),
),
],
],
),
),
],
);
}
}
class _HistoryGateway extends StatelessWidget {
const _HistoryGateway({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return SurfacePanel(
elevated: true,
padding: EdgeInsets.zero,
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 14, 14, 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
scheme.secondary.withValues(alpha: 0.10),
scheme.surfaceContainerLowest,
],
),
),
child: Row(
children: [
const SeedIllustration(
size: 68,
tone: SeedIllustrationTone.archive,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.viewHistory,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 3),
Text(
l10n.viewHistorySubtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 10),
Icon(
Icons.arrow_forward_ios_rounded,
size: 17,
color: scheme.primary,
),
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title, required this.detail});
final String title;
final String detail;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
Text(
detail,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
}
}
@@ -1,99 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/date_keys.dart';
import '../../../l10n/l10n.dart';
import '../../../models/app_state.dart';
import '../../../models/growth_stage.dart';
import '../../../widgets/surface_panel.dart';
class GrowthWeekStrip extends StatelessWidget {
const GrowthWeekStrip({required this.state, super.key});
final AppState state;
@override
Widget build(BuildContext context) {
final days = lastSevenDateKeys();
final l10n = context.l10n;
return SurfacePanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.sevenDayGrowth,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
l10n.sevenDayGrowthSubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
Row(
children: [
for (final day in days)
Expanded(
child: _GrowthDay(
day: day,
stage: growthStageFor(state, day),
),
),
],
),
],
),
);
}
}
class _GrowthDay extends StatelessWidget {
const _GrowthDay({required this.day, required this.stage});
final String day;
final GrowthStage stage;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final icon = switch (stage) {
GrowthStage.seed => Icons.circle_outlined,
GrowthStage.sprout => Icons.grass_rounded,
GrowthStage.leaves => Icons.eco_rounded,
GrowthStage.flower => Icons.local_florist_rounded,
};
return Semantics(
label: l10n.growthDaySemantic(
day.substring(5),
localizedGrowthStage(l10n, stage),
),
child: Column(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
stage == GrowthStage.seed
? theme.colorScheme.surfaceContainer
: theme.colorScheme.primary.withValues(alpha: 0.14),
),
child: Icon(
icon,
size: 19,
color:
stage == GrowthStage.seed
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.primary,
),
),
const SizedBox(height: 5),
Text(day.substring(8), style: theme.textTheme.labelSmall),
],
),
);
}
}
@@ -1,272 +0,0 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart';
import '../../../widgets/seed_illustration.dart';
import '../../../widgets/surface_panel.dart';
class JourneyOverviewPanel extends StatelessWidget {
const JourneyOverviewPanel({
required this.progress,
required this.onReflect,
super.key,
});
final TodayProgress progress;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final completed = progress.completedSteps;
final progressValue = completed / 4;
final title = switch (completed) {
0 => l10n.journeyOverviewStartTitle,
4 => l10n.journeyOverviewDoneTitle,
_ => l10n.journeyOverviewActiveTitle,
};
final subtitle = switch (completed) {
0 => l10n.journeyOverviewStartBody,
4 => l10n.journeyOverviewDoneBody,
_ => l10n.journeyOverviewActiveBody,
};
final showReflectAction =
progress.hasIntention && progress.hasFocus && !progress.hasReflection;
return SurfacePanel(
elevated: true,
padding: EdgeInsets.zero,
borderColor: scheme.outlineVariant.withValues(alpha: 0.45),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
scheme.primary.withValues(alpha: 0.20),
scheme.surfaceContainerLowest.withValues(alpha: 0.96),
scheme.secondary.withValues(alpha: 0.14),
],
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final textScale = MediaQuery.textScalerOf(context).scale(14) / 14;
final compact = constraints.maxWidth < 360 || textScale > 1.25;
final textContent = _OverviewText(
title: title,
subtitle: subtitle,
completed: completed,
progressValue: progressValue,
showReflectAction: showReflectAction,
onReflect: onReflect,
);
if (compact) {
return Stack(
children: [
const Positioned(
right: -18,
top: -20,
child: Opacity(
opacity: 0.58,
child: SeedIllustration(size: 128, hero: true),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 17, 106, 18),
child: textContent,
),
],
);
}
return Stack(
children: [
Positioned(
right: -28,
top: -22,
bottom: -18,
child: Opacity(
opacity: 0.96,
child: SeedIllustration(
size: constraints.maxWidth.clamp(146, 184).toDouble(),
hero: true,
),
),
),
Positioned(
right: 92,
bottom: 18,
child: _ProgressBadge(completed: completed),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 17, 168, 18),
child: textContent,
),
],
);
},
),
),
);
}
}
class _OverviewText extends StatelessWidget {
const _OverviewText({
required this.title,
required this.subtitle,
required this.completed,
required this.progressValue,
required this.showReflectAction,
required this.onReflect,
});
final String title;
final String subtitle;
final int completed;
final double progressValue;
final bool showReflectAction;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final scheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.journeyOverviewLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_ProgressSeeds(completed: completed, progressValue: progressValue),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
l10n.journeyOverviewProgress(completed),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge,
),
),
if (showReflectAction)
TextButton.icon(
onPressed: onReflect,
icon: const Icon(Icons.edit_note_rounded, size: 18),
label: Text(l10n.reviewNow),
),
],
),
],
);
}
}
class _ProgressSeeds extends StatelessWidget {
const _ProgressSeeds({required this.completed, required this.progressValue});
final int completed;
final double progressValue;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Semantics(
value: '${(progressValue * 100).round()}%',
child: Row(
children: List.generate(4, (index) {
final filled = index < completed;
return Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
height: 10,
margin: EdgeInsets.only(right: index == 3 ? 0 : 7),
decoration: BoxDecoration(
color:
filled
? scheme.primary
: scheme.surfaceContainerHigh.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(99),
boxShadow:
filled
? [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.28),
blurRadius: 10,
offset: const Offset(0, 4),
),
]
: null,
),
),
);
}),
),
);
}
}
class _ProgressBadge extends StatelessWidget {
const _ProgressBadge({required this.completed});
final int completed;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(99),
border: Border.all(
color: scheme.outlineVariant.withValues(alpha: 0.65),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.10),
blurRadius: 14,
offset: const Offset(0, 7),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 7),
child: Text(
'$completed/4',
style: theme.textTheme.labelLarge?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w900,
),
),
),
);
}
}
@@ -1,144 +0,0 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../models/journey_stats.dart';
import '../../../widgets/surface_panel.dart';
class JourneyStatsPanel extends StatelessWidget {
const JourneyStatsPanel({required this.stats, super.key});
final JourneyStats stats;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return SurfacePanel(
elevated: true,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.journeyStatsTitle,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 10),
LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth > 520 ? 4 : 2;
return GridView.count(
crossAxisCount: columns,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: columns == 4 ? 2.05 : 2.15,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
children: [
_StatItem(
icon: Icons.timer_outlined,
animatedTarget: stats.weekFocusMinutes,
valueBuilder: (value) => value.toString(),
label: l10n.weekFocusLabel,
),
_StatItem(
icon: Icons.local_fire_department_outlined,
animatedTarget: stats.currentStreakDays,
valueBuilder: l10n.daysValue,
label: l10n.streakLabel,
),
_StatItem(
icon: Icons.calendar_month_outlined,
animatedTarget: stats.activeDaysThisWeek,
valueBuilder: l10n.activeDaysValue,
label: l10n.activeDaysLabel,
),
_StatItem(
icon: Icons.check_circle_outline_rounded,
animatedTarget: (stats.habitCompletionRate * 100).round(),
valueBuilder: (value) => '$value%',
label: l10n.habitRateLabel,
),
],
);
},
),
],
),
);
}
}
class _StatItem extends StatelessWidget {
const _StatItem({
required this.icon,
required this.animatedTarget,
required this.valueBuilder,
required this.label,
});
final IconData icon;
final int animatedTarget;
final String Function(int value) valueBuilder;
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 10),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: scheme.primary, size: 19),
),
const SizedBox(width: 9),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TweenAnimationBuilder<double>(
tween: Tween(end: animatedTarget.toDouble()),
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
builder:
(context, animatedValue, _) => Text(
valueBuilder(animatedValue.round()),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
}
@@ -1,181 +0,0 @@
import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart';
import '../../../widgets/surface_panel.dart';
class JourneyTimeline extends StatelessWidget {
const JourneyTimeline({
required this.progress,
required this.onReflect,
super.key,
});
final TodayProgress progress;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final items = [
_TimelineData(
title: l10n.progressIntention,
detail:
progress.hasIntention ? progress.intention : l10n.chooseOneOutcome,
icon: Icons.flag_outlined,
complete: progress.hasIntention,
),
_TimelineData(
title: l10n.progressFocus,
detail:
progress.hasFocus
? l10n.focusedMinutes(progress.focusMinutes)
: l10n.noFocusSessionYet,
icon: Icons.timer_outlined,
complete: progress.hasFocus,
),
_TimelineData(
title: l10n.progressHabits,
detail: l10n.habitsCompleted(
progress.completedHabits,
progress.totalHabits,
),
icon: Icons.eco_outlined,
complete: progress.hasHabits,
),
_TimelineData(
title: l10n.progressReflect,
detail:
progress.hasReflection
? l10n.reflectionCaptured
: l10n.reflectionAction,
icon: Icons.auto_awesome_outlined,
complete: progress.hasReflection,
action: progress.hasReflection ? null : onReflect,
),
_TimelineData(
title: l10n.growthTitle,
detail:
progress.hasReflection ? l10n.growthFlowerOpen : l10n.growthLocked,
icon: Icons.local_florist_outlined,
complete: progress.hasReflection,
),
];
return Column(
children: [
for (var index = 0; index < items.length; index++)
_TimelineItem(data: items[index], isLast: index == items.length - 1),
],
);
}
}
class _TimelineItem extends StatelessWidget {
const _TimelineItem({required this.data, required this.isLast});
final _TimelineData data;
final bool isLast;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
final accent =
data.icon == Icons.auto_awesome_outlined
? visuals.reflection
: theme.colorScheme.primary;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 38,
child: Column(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
data.complete
? accent
: theme.colorScheme.surfaceContainerLowest,
border: Border.all(color: accent),
),
child: Icon(
data.complete ? Icons.check_rounded : data.icon,
size: 16,
color: data.complete ? theme.colorScheme.onPrimary : accent,
),
),
if (!isLast)
Expanded(
child: Container(
width: 2,
color:
data.complete
? accent.withValues(alpha: 0.55)
: theme.colorScheme.outlineVariant,
),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SurfacePanel(
padding: const EdgeInsets.all(14),
borderColor:
data.action == null ? null : accent.withValues(alpha: 0.5),
onTap: data.action,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(data.title, style: theme.textTheme.labelLarge),
const SizedBox(height: 3),
Text(
data.detail,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (data.action != null)
Icon(Icons.arrow_forward_rounded, color: accent),
],
),
),
),
),
],
),
);
}
}
class _TimelineData {
const _TimelineData({
required this.title,
required this.detail,
required this.icon,
required this.complete,
this.action,
});
final String title;
final String detail;
final IconData icon;
final bool complete;
final VoidCallback? action;
}
File diff suppressed because it is too large Load Diff
+122 -64
View File
@@ -1,22 +1,34 @@
import 'package:flutter/material.dart';
import '../../app/app_theme.dart';
import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart';
import '../../core/iterable_extensions.dart';
import '../../l10n/l10n.dart';
import '../../models/mood.dart';
import '../../widgets/app_bottom_sheet.dart';
import '../../widgets/app_busy_indicator.dart';
import '../../widgets/surface_panel.dart';
Future<void> showReflectionSheet(
BuildContext context,
FocusSeedController controller,
) {
FocusSeedController controller, {
String? dateKey,
String contextTitle = '',
}) {
final state = controller.state;
if (state == null) {
return Future.value();
}
final existing =
final targetDateKey = dateKey ?? todayKey();
final title = contextTitle.trim();
final existingReflection =
state.reflections
.where((entry) => entry.dateKey == todayKey())
.where((entry) => entry.dateKey == targetDateKey)
.firstOrNull;
final legacyProgress =
state.progressNotes
.where((entry) => entry.dateKey == targetDateKey)
.firstOrNull;
return showModalBottomSheet<void>(
context: context,
@@ -24,9 +36,15 @@ Future<void> showReflectionSheet(
useSafeArea: true,
builder:
(context) => _ReflectionComposer(
initialNote: existing?.note ?? '',
initialMood: existing?.mood ?? Mood.clear,
onSave: controller.saveReflection,
initialNote: existingReflection?.note ?? legacyProgress?.note ?? '',
contextTitle: title,
dateKey: targetDateKey,
onSave:
(note) => controller.saveReflectionForDate(
targetDateKey,
note,
existingReflection?.mood ?? Mood.clear,
),
),
);
}
@@ -34,13 +52,15 @@ Future<void> showReflectionSheet(
class _ReflectionComposer extends StatefulWidget {
const _ReflectionComposer({
required this.initialNote,
required this.initialMood,
required this.contextTitle,
required this.dateKey,
required this.onSave,
});
final String initialNote;
final Mood initialMood;
final Future<bool> Function(String note, Mood mood) onSave;
final String contextTitle;
final String dateKey;
final Future<bool> Function(String note) onSave;
@override
State<_ReflectionComposer> createState() => _ReflectionComposerState();
@@ -48,7 +68,6 @@ class _ReflectionComposer extends StatefulWidget {
class _ReflectionComposerState extends State<_ReflectionComposer> {
late final TextEditingController _controller;
late Mood _mood;
bool _saving = false;
String? _errorText;
@@ -56,7 +75,6 @@ class _ReflectionComposerState extends State<_ReflectionComposer> {
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialNote);
_mood = widget.initialMood;
}
@override
@@ -75,7 +93,7 @@ class _ReflectionComposerState extends State<_ReflectionComposer> {
_saving = true;
_errorText = null;
});
final saved = await widget.onSave(_controller.text, _mood);
final saved = await widget.onSave(_controller.text);
if (!mounted) {
return;
}
@@ -92,72 +110,112 @@ class _ReflectionComposerState extends State<_ReflectionComposer> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Padding(
padding: EdgeInsets.fromLTRB(
20,
4,
20,
MediaQuery.viewInsetsOf(context).bottom + 24,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.reflectionSheetTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
l10n.reflectionSheetSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final visuals = FocusSeedVisuals.of(context);
return AppBottomSheetScaffold(
title: l10n.reflectionSheetTitle,
subtitle: l10n.reflectionSheetSubtitle,
icon: Icons.edit_note_rounded,
accent: visuals.reflection,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.contextTitle.isNotEmpty) ...[
SurfacePanel(
radius: 18,
padding: const EdgeInsets.fromLTRB(12, 11, 12, 11),
color: Color.alphaBlend(
visuals.action.withValues(alpha: 0.045),
scheme.surfaceContainerLowest,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.flag_outlined, color: visuals.action, size: 19),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.todayIntentionLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: visuals.action,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 2),
Text(
widget.contextTitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w900,
height: 1.18,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final mood in Mood.values)
ChoiceChip(
avatar: Icon(mood.icon, size: 17),
label: Text(localizedMood(l10n, mood)),
selected: _mood == mood,
onSelected: (_) => setState(() => _mood = mood),
),
],
),
const SizedBox(height: 14),
TextField(
const SizedBox(height: 12),
],
SurfacePanel(
radius: 18,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
tone: SurfacePanelTone.paper,
child: TextField(
controller: _controller,
autofocus: widget.initialNote.isEmpty,
minLines: 4,
maxLines: 7,
maxLength: 280,
scrollPadding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom + 32,
),
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: l10n.reflectionHint,
errorText: _errorText,
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon:
_saving
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome_outlined),
label: Text(_saving ? l10n.savingLabel : l10n.saveReflection),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon:
_saving
? AppBusyIndicator(semanticLabel: l10n.savingLabel)
: const Icon(Icons.auto_awesome_outlined),
label: Text(_saving ? l10n.savingLabel : l10n.saveReflection),
style: FilledButton.styleFrom(
backgroundColor: visuals.reflection,
foregroundColor:
ThemeData.estimateBrightnessForColor(visuals.reflection) ==
Brightness.dark
? Colors.white
: scheme.onSurface,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
textStyle: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
],
),
),
],
),
);
}
+304 -59
View File
@@ -1,15 +1,22 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../app/app_theme.dart';
import '../../application/focus_seed_controller.dart';
import '../../application/focus_timer_controller.dart';
import '../../data/focus_seed_store.dart';
import '../../data/focus_timer_session_store.dart';
import '../../l10n/l10n.dart';
import '../../models/appearance_preference.dart';
import '../../models/language_preference.dart';
import '../../widgets/loading_view.dart';
import '../journey/journey_screen.dart';
import '../../widgets/tactile_canvas.dart';
import '../focus/focus_screen.dart';
import '../me/me_screen.dart';
import '../onboarding/landing_setup_screen.dart';
import '../records/records_screen.dart';
import '../today/today_screen.dart';
class FocusSeedHome extends StatefulWidget {
@@ -20,6 +27,8 @@ class FocusSeedHome extends StatefulWidget {
required this.showLanguageTools,
required this.onLanguageChanged,
this.focusSeedStore,
this.showLandingSetupInitially = false,
this.onLandingSetupComplete,
super.key,
});
@@ -29,6 +38,8 @@ class FocusSeedHome extends StatefulWidget {
final bool showLanguageTools;
final ValueChanged<LanguagePreference> onLanguageChanged;
final FocusSeedStore? focusSeedStore;
final bool showLandingSetupInitially;
final VoidCallback? onLandingSetupComplete;
@override
State<FocusSeedHome> createState() => _FocusSeedHomeState();
@@ -38,6 +49,13 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
FocusSeedController? _controller;
FocusTimerController? _timerController;
int _tabIndex = 0;
late bool _landingSetupVisible;
@override
void initState() {
super.initState();
_landingSetupVisible = widget.showLandingSetupInitially;
}
@override
void didChangeDependencies() {
@@ -50,14 +68,41 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
defaultHabitTitles: localizedStarterHabits(context.l10n),
);
_timerController = FocusTimerController(
onCompleted: (minutes) async {
final saved = await _controller!.completeFocusSession(minutes);
sessionStore: FocusTimerSessionStore(),
onCompleted: (minutes, actualSeconds) async {
final saved = await _controller!.completeFocusSession(
minutes,
actualSeconds: actualSeconds,
);
if (saved) {
await HapticFeedback.mediumImpact();
}
return saved;
},
);
_controller!.load();
unawaited(_loadState());
}
Future<void> _loadState() async {
final controller = _controller;
final timerController = _timerController;
if (controller == null || timerController == null) {
return;
}
await controller.load();
final defaultMinutes = controller.state?.defaultFocusMinutes;
if (!mounted || defaultMinutes == null) {
return;
}
final restored = await timerController.restoreSavedSession();
if (!mounted) {
return;
}
if (restored) {
setState(() => _tabIndex = 1);
return;
}
timerController.selectMinutes(defaultMinutes);
}
@override
@@ -109,63 +154,101 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
}
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor:
theme.navigationBarTheme.backgroundColor ??
theme.colorScheme.surface,
systemNavigationBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
),
child: Scaffold(
body: SafeArea(
child: IndexedStack(
index: _tabIndex,
children: [
TodayScreen(
controller: controller,
timerController: timerController,
),
JourneyScreen(controller: controller),
MeScreen(
controller: controller,
appearance: widget.appearance,
onAppearanceChanged: widget.onAppearanceChanged,
language: widget.language,
showLanguageTools: widget.showLanguageTools,
onLanguageChanged: widget.onLanguageChanged,
),
],
void openMe() => setState(() => _tabIndex = 3);
void completeLandingSetup({bool openFocus = false}) {
widget.onLandingSetupComplete?.call();
setState(() {
_landingSetupVisible = false;
if (openFocus) {
_tabIndex = 1;
}
});
}
return AnimatedBuilder(
animation: timerController,
builder: (context, _) {
final hideBottomNavigation =
!_landingSetupVisible &&
_tabIndex == 1 &&
(timerController.isRunning || timerController.isPaused);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor:
theme.navigationBarTheme.backgroundColor ??
theme.colorScheme.surface,
systemNavigationBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _tabIndex,
onDestinationSelected: (index) {
HapticFeedback.selectionClick();
setState(() => _tabIndex = index);
},
destinations: [
NavigationDestination(
icon: const Icon(Icons.eco_outlined),
selectedIcon: const Icon(Icons.eco_rounded),
label: context.l10n.navToday,
child: Scaffold(
body: TactileCanvas(
child:
_landingSetupVisible
? LandingSetupScreen(
controller: controller,
timerController: timerController,
onDone: completeLandingSetup,
onStartFocus:
() => completeLandingSetup(openFocus: true),
)
: SafeArea(
child: IndexedStack(
index: _tabIndex,
children: [
TodayScreen(
controller: controller,
timerController: timerController,
onOpenSettings: openMe,
onOpenFocus:
() => setState(() => _tabIndex = 1),
),
FocusScreen(
controller: controller,
timerController: timerController,
onBackToday:
() => setState(() => _tabIndex = 0),
),
RecordsScreen(
controller: controller,
onStartToday:
() => setState(() => _tabIndex = 0),
),
MeScreen(
controller: controller,
appearance: widget.appearance,
onAppearanceChanged:
widget.onAppearanceChanged,
language: widget.language,
showLanguageTools: widget.showLanguageTools,
onLanguageChanged: widget.onLanguageChanged,
onDefaultFocusMinutesChanged: (minutes) {
if (!timerController.isRunning &&
!timerController.isPaused) {
timerController.selectMinutes(minutes);
}
},
embedded: true,
),
],
),
),
),
NavigationDestination(
icon: const Icon(Icons.landscape_outlined),
selectedIcon: const Icon(Icons.landscape_rounded),
label: context.l10n.navJourney,
),
NavigationDestination(
icon: const Icon(Icons.person_outline_rounded),
selectedIcon: const Icon(Icons.person_rounded),
label: context.l10n.navMe,
),
],
),
),
bottomNavigationBar:
_landingSetupVisible || hideBottomNavigation
? null
: _FocusSeedNavigationBar(
selectedIndex: _tabIndex,
onDestinationSelected: (index) {
HapticFeedback.selectionClick();
setState(() => _tabIndex = index);
},
),
),
);
},
);
},
);
@@ -179,6 +262,168 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
}
}
class _FocusSeedNavigationBar extends StatelessWidget {
const _FocusSeedNavigationBar({
required this.selectedIndex,
required this.onDestinationSelected,
});
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final l10n = context.l10n;
final destinations = [
_FocusSeedNavigationDestination(
key: 'today',
icon: Icons.home_outlined,
label: l10n.navToday,
),
_FocusSeedNavigationDestination(
key: 'focus',
icon: Icons.timer_outlined,
label: l10n.progressFocus,
),
_FocusSeedNavigationDestination(
key: 'records',
icon: Icons.library_books_outlined,
label: l10n.navRecords,
),
_FocusSeedNavigationDestination(
key: 'me',
icon: Icons.person_outline_rounded,
label: l10n.navMe,
),
];
return SafeArea(
top: false,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest.withValues(alpha: 0.98),
border: Border(
top: BorderSide(
color: scheme.outlineVariant.withValues(alpha: 0.58),
),
),
),
child: Material(
color: Colors.transparent,
child: SizedBox(
height: 72,
child: Row(
children: [
for (var index = 0; index < destinations.length; index++)
Expanded(
child: _FocusSeedNavigationItem(
destination: destinations[index],
selected: index == selectedIndex,
onTap: () => onDestinationSelected(index),
),
),
],
),
),
),
),
);
}
}
class _FocusSeedNavigationDestination {
const _FocusSeedNavigationDestination({
required this.key,
required this.icon,
required this.label,
});
final String key;
final IconData icon;
final String label;
}
class _FocusSeedNavigationItem extends StatelessWidget {
const _FocusSeedNavigationItem({
required this.destination,
required this.selected,
required this.onTap,
});
final _FocusSeedNavigationDestination destination;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
final isDark = theme.brightness == Brightness.dark;
final selectedColor = visuals.focus;
final inactiveColor =
isDark ? theme.colorScheme.onSurfaceVariant : const Color(0xFF4B5563);
final foregroundColor = selected ? selectedColor : inactiveColor;
return Semantics(
button: true,
selected: selected,
label: destination.label,
child: InkWell(
key: ValueKey('focus-seed-nav-${destination.key}'),
onTap: onTap,
child: SizedBox.expand(
child: Stack(
children: [
Align(
alignment: Alignment.topCenter,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: selected ? 34 : 0,
height: 3,
decoration: BoxDecoration(
color: selectedColor,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(3),
bottomRight: Radius.circular(3),
),
),
),
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(destination.icon, size: 23, color: foregroundColor),
const SizedBox(height: 5),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
style: theme.textTheme.labelMedium!.copyWith(
color: foregroundColor,
fontSize: 12,
fontWeight:
selected ? FontWeight.w800 : FontWeight.w600,
letterSpacing: 0,
height: 1.1,
),
child: Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
),
);
}
}
String? _messageForError(AppLocalizations l10n, FocusSeedError? error) {
return switch (error) {
FocusSeedError.openFailed => l10n.openFailed,
+645 -45
View File
@@ -1,56 +1,67 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../app/app_theme.dart';
import '../../application/focus_seed_controller.dart';
import '../../application/focus_timer_controller.dart';
import '../../core/date_keys.dart';
import '../../core/iterable_extensions.dart';
import '../../l10n/l10n.dart';
import '../../models/habit.dart';
import '../../models/reflection_entry.dart';
import '../../models/today_progress.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/focus_seed_header.dart';
import '../../widgets/surface_panel.dart';
import '../../widgets/experience_motion.dart';
import '../../widgets/workbench_ui.dart';
import '../habits/habit_manager_sheet.dart';
import '../reflection/reflection_sheet.dart';
import 'widgets/daily_progress_rail.dart';
import 'widgets/focus_session_card.dart';
import 'widgets/habit_section.dart';
import 'widgets/intention_card.dart';
import 'widgets/reflection_prompt.dart';
class TodayScreen extends StatelessWidget {
const TodayScreen({
required this.controller,
required this.timerController,
required this.onOpenSettings,
required this.onOpenFocus,
super.key,
});
final FocusSeedController controller;
final FocusTimerController timerController;
final VoidCallback onOpenSettings;
final VoidCallback onOpenFocus;
@override
Widget build(BuildContext context) {
final state = controller.state!;
final l10n = context.l10n;
final now = DateTime.now();
final today = todayKey();
final progress = TodayProgress.fromState(state, today);
final reflection =
state.reflections.where((entry) => entry.dateKey == today).firstOrNull;
final reflection = _reflectionForToday(state.reflections, today);
final activeSession = timerController.isRunning || timerController.isPaused;
void editIntention() {
showIntentionDialog(
void startFocus() {
unawaited(HapticFeedback.selectionClick());
timerController.start();
onOpenFocus();
}
void openReflection() {
showReflectionSheet(
context,
controller.setTodayAnchor,
initialValue: progress.intention,
controller,
dateKey: today,
contextTitle: progress.intention,
);
}
return Column(
children: [
FocusSeedHeader(
title: localizedGreeting(l10n, now),
subtitle: localizedReadableDate(context, now),
isSaving: controller.isSaving,
WorkbenchTopBar(
title: l10n.brandName,
trailingIcon: Icons.lock_outline_rounded,
trailingTooltip: l10n.privacyTitle,
onTrailingPressed: () => showPrivacySheet(context),
),
Expanded(
child: ListView(
@@ -62,37 +73,46 @@ class TodayScreen extends StatelessWidget {
),
const SizedBox(height: 10),
],
IntentionCard(
intention: progress.intention,
onEdit: editIntention,
StaggeredReveal(
child: _TodayCommandPanel(
progress: progress,
timerController: timerController,
activeSession: activeSession,
onEditFocus:
() => showIntentionDialog(
context,
controller.setTodayAnchor,
initialValue: progress.intention,
),
onStartFocus: startFocus,
onOpenFocus: onOpenFocus,
onReflect: openReflection,
),
),
const SizedBox(height: 12),
FocusSessionCard(
controller: timerController,
growthStage: progress.growthStage,
hasIntention: progress.hasIntention,
onSetIntention: editIntention,
onReflect: () => showReflectionSheet(context, controller),
StaggeredReveal(
order: 1,
child: _HabitWorkbenchSection(
habits: state.habits,
dateKey: today,
onToggle: (habit) => unawaited(controller.toggleHabit(habit)),
onAdd: () => showHabitDialog(context, controller.addHabit),
onManage: () => showHabitManagerSheet(context, controller),
),
),
const SizedBox(height: 12),
SurfacePanel(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
child: DailyProgressRail(progress: progress),
StaggeredReveal(
order: 2,
child: _ReflectionWorkbenchSection(
reflection: reflection,
onTap: openReflection,
),
),
const SizedBox(height: 12),
HabitSection(
habits: state.habits,
dateKey: today,
onToggle: controller.toggleHabit,
onAdd: () => showHabitDialog(context, controller.addHabit),
onManage: () => showHabitManagerSheet(context, controller),
StaggeredReveal(
order: 3,
child: _TodayLedgerStrip(progress: progress),
),
const SizedBox(height: 12),
ReflectionPrompt(
reflection: reflection,
onTap: () => showReflectionSheet(context, controller),
),
const SizedBox(height: 12),
],
),
),
@@ -108,6 +128,568 @@ String _messageForError(AppLocalizations l10n, FocusSeedError error) {
};
}
ReflectionEntry? _reflectionForToday(
List<ReflectionEntry> reflections,
String today,
) {
final matches =
reflections.where((entry) => entry.dateKey == today).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return matches.isEmpty ? null : matches.first;
}
class _TodayCommandPanel extends StatelessWidget {
const _TodayCommandPanel({
required this.progress,
required this.timerController,
required this.activeSession,
required this.onEditFocus,
required this.onStartFocus,
required this.onOpenFocus,
required this.onReflect,
});
final TodayProgress progress;
final FocusTimerController timerController;
final bool activeSession;
final VoidCallback onEditFocus;
final VoidCallback onStartFocus;
final VoidCallback onOpenFocus;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final visuals = FocusSeedVisuals.of(context);
final scheme = Theme.of(context).colorScheme;
final title =
progress.hasIntention ? progress.intention : l10n.chooseOneOutcome;
final status = _todayStatus(context);
final primary = _primaryAction(context);
return WorkbenchPanel(
accent: visuals.action,
fill: Color.alphaBlend(
visuals.action.withValues(alpha: 0.035),
scheme.surfaceContainerLowest,
),
padding: const EdgeInsets.fromLTRB(16, 15, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
WorkbenchHeader(
eyebrow: l10n.todayIntentionLabel,
title: title,
subtitle: status.$2,
icon: activeSession ? Icons.timer_outlined : Icons.flag_outlined,
accent: status.$3,
trailing: IconButton(
tooltip:
progress.hasIntention
? l10n.editIntentionTitle
: l10n.setIntentionTitle,
onPressed: onEditFocus,
style: IconButton.styleFrom(
minimumSize: const Size(40, 40),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kWorkbenchRadius),
),
),
icon: const Icon(Icons.edit_outlined, size: 20),
),
),
const SizedBox(height: 14),
_TodayStepTrack(progress: progress, activeSession: activeSession),
const SizedBox(height: 14),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
child: _TodayPrimaryAction(
key: ValueKey(primary.$1),
label: primary.$2,
icon: primary.$3,
accent: primary.$4,
onPressed: primary.$5,
),
),
if (!progress.hasIntention && !activeSession) ...[
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: TextButton.icon(
onPressed: onEditFocus,
icon: const Icon(Icons.edit_outlined),
label: Text(l10n.setTodaysIntention),
),
),
],
],
),
);
}
(String, String, Color) _todayStatus(BuildContext context) {
final l10n = context.l10n;
final visuals = FocusSeedVisuals.of(context);
if (timerController.isRunning) {
return (
'running',
'${l10n.timerRunning} · ${formatSeconds(timerController.remainingSeconds)}',
visuals.focus,
);
}
if (timerController.isPaused) {
return (
'paused',
'${l10n.timerPaused} · ${formatSeconds(timerController.remainingSeconds)}',
visuals.habit,
);
}
if (progress.isLoopClosed) {
return ('closed', l10n.todayLoopDoneBody, visuals.success);
}
if (progress.hasFocus && !progress.hasReflection) {
return ('reflect', l10n.nextActionReflectBody, visuals.reflection);
}
if (progress.hasIntention) {
return ('ready', l10n.nextActionStartActionBody, visuals.focus);
}
return ('empty', l10n.todayLoopStartBody, visuals.action);
}
(String, String, IconData, Color, VoidCallback) _primaryAction(
BuildContext context,
) {
final l10n = context.l10n;
final visuals = FocusSeedVisuals.of(context);
if (activeSession) {
return (
'continue',
l10n.continueSession,
Icons.arrow_forward_rounded,
visuals.focus,
onOpenFocus,
);
}
if (progress.hasFocus && !progress.hasReflection) {
return (
'reflect',
l10n.reflectionAction,
Icons.edit_note_rounded,
visuals.reflection,
onReflect,
);
}
return (
'start',
l10n.startMinutes(timerController.selectedMinutes),
Icons.play_arrow_rounded,
visuals.focus,
onStartFocus,
);
}
}
class _TodayPrimaryAction extends StatelessWidget {
const _TodayPrimaryAction({
required this.label,
required this.icon,
required this.accent,
required this.onPressed,
super.key,
});
final String label;
final IconData icon;
final Color accent;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor:
ThemeData.estimateBrightnessForColor(accent) == Brightness.dark
? Colors.white
: scheme.onSurface,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kWorkbenchRadius),
),
),
icon: Icon(icon),
label: Text(label),
),
);
}
}
class _TodayStepTrack extends StatelessWidget {
const _TodayStepTrack({required this.progress, required this.activeSession});
final TodayProgress progress;
final bool activeSession;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final visuals = FocusSeedVisuals.of(context);
final items = [
_StepData(
label: l10n.progressIntention,
icon: Icons.flag_outlined,
complete: progress.hasIntention,
accent: visuals.action,
),
_StepData(
label: l10n.progressFocus,
icon: Icons.timer_outlined,
complete: progress.hasFocus || activeSession,
accent: visuals.focus,
),
_StepData(
label: l10n.progressReflect,
icon: Icons.edit_note_rounded,
complete: progress.hasReflection,
accent: visuals.reflection,
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
WorkbenchProgressTrack(
value: progress.completedSteps / 3,
accent: progress.isLoopClosed ? visuals.success : visuals.focus,
),
const SizedBox(height: 9),
Row(
children: [
for (var index = 0; index < items.length; index++) ...[
Expanded(child: _StepMarker(data: items[index])),
if (index != items.length - 1) const SizedBox(width: 8),
],
],
),
],
);
}
}
class _StepData {
const _StepData({
required this.label,
required this.icon,
required this.complete,
required this.accent,
});
final String label;
final IconData icon;
final bool complete;
final Color accent;
}
class _StepMarker extends StatelessWidget {
const _StepMarker({required this.data});
final _StepData data;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final color = data.complete ? data.accent : scheme.outline;
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
decoration: BoxDecoration(
color:
data.complete
? data.accent.withValues(alpha: 0.10)
: scheme.surfaceContainerLow.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(kWorkbenchRadius),
),
child: Row(
children: [
Icon(
data.complete ? Icons.check_rounded : data.icon,
color: color,
size: 15,
),
const SizedBox(width: 5),
Expanded(
child: Text(
data.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color:
data.complete ? scheme.onSurface : scheme.onSurfaceVariant,
fontWeight: FontWeight.w800,
letterSpacing: 0,
),
),
),
],
),
);
}
}
class _HabitWorkbenchSection extends StatelessWidget {
const _HabitWorkbenchSection({
required this.habits,
required this.dateKey,
required this.onToggle,
required this.onAdd,
required this.onManage,
});
final List<Habit> habits;
final String dateKey;
final ValueChanged<Habit> onToggle;
final VoidCallback onAdd;
final VoidCallback onManage;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final visuals = FocusSeedVisuals.of(context);
final completed =
habits.where((habit) {
return habit.completedDates.contains(dateKey);
}).length;
return WorkbenchPanel(
accent: visuals.habit,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
WorkbenchSectionTitle(
title: l10n.todaysHabits,
icon: Icons.add_task_rounded,
accent: visuals.habit,
trailing: WorkbenchPill(
label: l10n.completedCount(completed, habits.length),
accent: visuals.habit,
),
),
const SizedBox(height: 8),
if (habits.isEmpty)
_QuietText(text: l10n.emptyHabitPrompt)
else
Column(
children: [
for (var index = 0; index < habits.length; index++) ...[
if (index > 0) const Divider(height: 1),
_HabitWorkbenchRow(
habit: habits[index],
dateKey: dateKey,
onToggle: () => onToggle(habits[index]),
),
],
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addHabit),
),
),
const SizedBox(width: 8),
Expanded(
child: TextButton.icon(
onPressed: onManage,
icon: const Icon(Icons.tune_rounded),
label: Text(l10n.manageHabits),
),
),
],
),
],
),
);
}
}
class _HabitWorkbenchRow extends StatelessWidget {
const _HabitWorkbenchRow({
required this.habit,
required this.dateKey,
required this.onToggle,
});
final Habit habit;
final String dateKey;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
final visuals = FocusSeedVisuals.of(context);
final done = habit.completedDates.contains(dateKey);
return WorkbenchListRow(
title: habit.title,
accent: visuals.habit,
leadingIcon:
done ? Icons.check_circle_rounded : Icons.radio_button_unchecked,
trailing: Checkbox(
value: done,
activeColor: visuals.habit,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
onChanged: (_) => onToggle(),
),
onTap: onToggle,
);
}
}
class _ReflectionWorkbenchSection extends StatelessWidget {
const _ReflectionWorkbenchSection({
required this.reflection,
required this.onTap,
});
final ReflectionEntry? reflection;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final visuals = FocusSeedVisuals.of(context);
final note = reflection?.note.trim() ?? '';
final hasNote = note.isNotEmpty;
return WorkbenchPanel(
accent: visuals.reflection,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
WorkbenchSectionTitle(
title: l10n.progressReflect,
icon: Icons.edit_note_rounded,
accent: visuals.reflection,
trailing: Icon(
hasNote ? Icons.check_rounded : Icons.add_rounded,
color: visuals.reflection,
size: 20,
),
),
const SizedBox(height: 10),
Text(
hasNote ? note : l10n.recordsNoReflection,
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: hasNote ? FontWeight.w800 : FontWeight.w600,
height: 1.28,
),
),
if (!hasNote) ...[
const SizedBox(height: 10),
WorkbenchPill(
label: l10n.reflectionAction,
icon: Icons.arrow_forward_rounded,
accent: visuals.reflection,
),
],
],
),
);
}
}
class _TodayLedgerStrip extends StatelessWidget {
const _TodayLedgerStrip({required this.progress});
final TodayProgress progress;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final visuals = FocusSeedVisuals.of(context);
return WorkbenchPanel(
accent: visuals.privacy,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
WorkbenchSectionTitle(
title: l10n.todayLoopLabel,
icon: Icons.inventory_2_outlined,
accent: visuals.privacy,
trailing: WorkbenchPill(
label: l10n.local,
icon: Icons.lock_outline_rounded,
accent: visuals.privacy,
),
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 380;
final children = [
WorkbenchMetricCell(
label: l10n.progressFocus,
value: l10n.minutesShort(progress.focusMinutes),
icon: Icons.timer_outlined,
accent: visuals.focus,
),
WorkbenchMetricCell(
label: l10n.progressHabits,
value: l10n.completedCount(
progress.completedHabits,
progress.totalHabits,
),
icon: Icons.add_task_rounded,
accent: visuals.habit,
),
WorkbenchMetricCell(
label: l10n.progressReflect,
value:
progress.hasReflection
? l10n.done
: l10n.recordsNoReflection,
icon: Icons.edit_note_rounded,
accent: visuals.reflection,
),
];
if (compact) {
return Column(
children: [
for (var index = 0; index < children.length; index++) ...[
if (index > 0) const SizedBox(height: 8),
children[index],
],
],
);
}
return Row(
children: [
for (var index = 0; index < children.length; index++) ...[
Expanded(child: children[index]),
if (index != children.length - 1) const SizedBox(width: 8),
],
],
);
},
),
],
),
);
}
}
class _InlineError extends StatelessWidget {
const _InlineError({required this.message});
@@ -122,7 +704,7 @@ class _InlineError extends StatelessWidget {
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(kWorkbenchRadius),
),
child: Row(
children: [
@@ -140,3 +722,21 @@ class _InlineError extends StatelessWidget {
);
}
}
class _QuietText extends StatelessWidget {
const _QuietText({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
height: 1.28,
),
);
}
}
@@ -1,7 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart';
import '../../../models/habit.dart';
import '../../../widgets/app_motion.dart';
import '../../../widgets/experience_motion.dart';
import '../../../widgets/pressable_scale.dart';
class CompactHabitRow extends StatelessWidget {
const CompactHabitRow({
@@ -28,75 +33,119 @@ class CompactHabitRow extends StatelessWidget {
button: true,
checked: complete,
label: habit.title,
child: InkWell(
onTap: onToggle,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
complete ? theme.colorScheme.primary : Colors.transparent,
border: Border.all(
color:
complete
? theme.colorScheme.primary
: theme.colorScheme.outline,
child: PressableScale(
child: InkWell(
onTap: () {
HapticFeedback.selectionClick();
onToggle();
},
child: AnimatedContainer(
duration: AppMotion.duration(context, AppMotion.fast),
curve: AppMotion.emphasized,
margin: const EdgeInsets.symmetric(vertical: 3),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
decoration: BoxDecoration(
color:
complete
? FocusSeedVisuals.of(
context,
).success.withValues(alpha: 0.055)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
_HabitCheckControl(complete: complete),
const SizedBox(width: 12),
Expanded(
child: AnimatedDefaultTextStyle(
duration: AppMotion.duration(context, AppMotion.fast),
style:
theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight:
complete ? FontWeight.w700 : FontWeight.w500,
) ??
const TextStyle(),
child: Text(
habit.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: Icon(
complete ? Icons.eco_rounded : Icons.circle_outlined,
key: ValueKey(complete),
size: 16,
color:
complete
? theme.colorScheme.onPrimary
: Colors.transparent,
if (onEdit != null || onDelete != null)
PopupMenuButton<String>(
tooltip: l10n.habitOptions,
onSelected: (value) {
if (value == 'edit') {
onEdit?.call();
} else if (value == 'delete') {
onDelete?.call();
}
},
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Text(l10n.rename),
),
PopupMenuItem(
value: 'delete',
child: Text(l10n.delete),
),
],
),
),
),
const SizedBox(width: 11),
Expanded(
child: Text(
habit.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color:
complete
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
decoration: complete ? TextDecoration.lineThrough : null,
),
),
),
if (onEdit != null || onDelete != null)
PopupMenuButton<String>(
tooltip: l10n.habitOptions,
onSelected: (value) {
if (value == 'edit') {
onEdit?.call();
} else if (value == 'delete') {
onDelete?.call();
}
},
itemBuilder:
(context) => [
PopupMenuItem(value: 'edit', child: Text(l10n.rename)),
PopupMenuItem(
value: 'delete',
child: Text(l10n.delete),
),
],
),
],
],
),
),
),
),
);
}
}
class _HabitCheckControl extends StatelessWidget {
const _HabitCheckControl({required this.complete});
final bool complete;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final visuals = FocusSeedVisuals.of(context);
return SizedBox.square(
dimension: 32,
child: Center(
child: AnimatedScale(
scale: complete ? 1.02 : 1,
duration: AppMotion.duration(context, AppMotion.fast),
curve: AppMotion.settle,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow:
complete
? [
BoxShadow(
color: visuals.success.withValues(alpha: 0.16),
blurRadius: 9,
offset: const Offset(0, 4),
),
]
: [
BoxShadow(
color: scheme.shadow.withValues(alpha: 0.025),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: AnimatedCheckMark(
checked: complete,
color: visuals.success,
size: 25,
),
),
),
),
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart';
import '../../../widgets/app_motion.dart';
class DailyProgressRail extends StatelessWidget {
const DailyProgressRail({required this.progress, super.key});
@@ -27,16 +29,10 @@ class DailyProgressRail extends StatelessWidget {
icon: Icons.timer_outlined,
complete: progress.hasFocus,
),
_ProgressItem(
label: l10n.progressHabits,
detail: '${progress.completedHabits}/${progress.totalHabits}',
icon: Icons.eco_outlined,
complete: progress.hasHabits,
),
_ProgressItem(
label: l10n.progressReflect,
detail: progress.hasReflection ? l10n.done : l10n.later,
icon: Icons.auto_awesome_outlined,
icon: Icons.edit_note_rounded,
complete: progress.hasReflection,
),
];
@@ -60,71 +56,94 @@ class _HorizontalProgress extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final visuals = FocusSeedVisuals.of(context);
final lastCompleteIndex = items.lastIndexWhere((item) => item.complete);
final progress =
lastCompleteIndex <= 0 ? 0.0 : lastCompleteIndex / (items.length - 1);
return Semantics(
label: context.l10n.dailyStepsComplete(
items.where((item) => item.complete).length,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
child: Column(
children: [
for (var index = 0; index < items.length; index++)
Expanded(
child: Column(
children: [
SizedBox(
height: 34,
child: Stack(
alignment: Alignment.center,
children: [
if (index > 0)
Align(
alignment: Alignment.centerLeft,
child: Container(
width: double.infinity,
height: 2,
color:
items[index - 1].complete
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
),
SizedBox(
height: 32,
child: LayoutBuilder(
builder: (context, constraints) {
final inset = constraints.maxWidth / (items.length * 2);
return Stack(
alignment: Alignment.center,
children: [
Positioned(
left: inset,
right: inset,
top: 15,
child: _ProgressTrack(
color: scheme.outlineVariant.withValues(alpha: 0.72),
),
),
Positioned(
left: inset,
right: inset,
top: 15,
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: progress.clamp(0.0, 1.0),
child: _ProgressTrack(
color: visuals.success,
glow: visuals.success.withValues(alpha: 0.20),
),
if (index < items.length - 1)
Align(
alignment: Alignment.centerRight,
child: Container(
width: double.infinity,
height: 2,
color:
items[index].complete
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
),
),
),
Positioned.fill(
child: Row(
children: [
for (final item in items)
Expanded(
child: Center(child: _ProgressDot(item: item)),
),
),
_ProgressDot(item: items[index]),
],
],
),
),
),
const SizedBox(height: 5),
Text(
items[index].label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
items[index].detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
),
),
],
),
],
);
},
),
),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final item in items)
Expanded(
child: Column(
children: [
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 1),
Text(
item.detail,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
fontSize: 10.5,
),
),
],
),
),
],
),
],
),
);
@@ -161,6 +180,28 @@ class _VerticalProgress extends StatelessWidget {
}
}
class _ProgressTrack extends StatelessWidget {
const _ProgressTrack({required this.color, this.glow});
final Color color;
final Color? glow;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: AppMotion.duration(context, AppMotion.medium),
curve: AppMotion.emphasized,
height: 2,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(99),
boxShadow:
glow == null ? null : [BoxShadow(color: glow!, blurRadius: 10)],
),
);
}
}
class _ProgressDot extends StatelessWidget {
const _ProgressDot({required this.item});
@@ -169,29 +210,51 @@ class _ProgressDot extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
item.complete
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerLowest,
border: Border.all(
color:
final scheme = theme.colorScheme;
final visuals = FocusSeedVisuals.of(context);
return AnimatedScale(
scale: item.complete ? 1.08 : 1,
duration: AppMotion.duration(context, AppMotion.fast),
curve: AppMotion.settle,
child: AnimatedContainer(
duration: AppMotion.duration(context, AppMotion.medium),
curve: AppMotion.emphasized,
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: item.complete ? visuals.success : scheme.surfaceContainerLow,
border: Border.all(
color: item.complete ? visuals.success : scheme.outlineVariant,
),
boxShadow:
item.complete
? theme.colorScheme.primary
: theme.colorScheme.outline,
? [
BoxShadow(
color: visuals.success.withValues(alpha: 0.20),
blurRadius: 8,
offset: const Offset(0, 3),
),
]
: null,
),
child: AnimatedSwitcher(
duration: AppMotion.duration(context, AppMotion.fast),
switchInCurve: AppMotion.settle,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(scale: animation, child: child),
);
},
child: Icon(
item.complete ? Icons.check_rounded : item.icon,
key: ValueKey('${item.label}-${item.complete}'),
size: 15,
color: item.complete ? scheme.onPrimary : scheme.onSurfaceVariant,
),
),
),
child: Icon(
item.complete ? Icons.check_rounded : item.icon,
size: 17,
color:
item.complete
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
);
}
@@ -1,266 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../application/focus_timer_controller.dart';
import '../../../core/date_keys.dart';
import '../../../l10n/l10n.dart';
import '../../../models/growth_stage.dart';
import '../../../widgets/growth_object.dart';
import '../../../widgets/surface_panel.dart';
class FocusSessionCard extends StatelessWidget {
const FocusSessionCard({
required this.controller,
required this.growthStage,
required this.hasIntention,
required this.onSetIntention,
required this.onReflect,
super.key,
});
final FocusTimerController controller;
final GrowthStage growthStage;
final bool hasIntention;
final VoidCallback onSetIntention;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
return SurfacePanel(
elevated: true,
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.focusLabel,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
LayoutBuilder(
builder: (context, constraints) {
final textScale =
MediaQuery.textScalerOf(context).scale(16) / 16;
final stack = constraints.maxWidth < 260 || textScale > 1.6;
final details = _TimerDetails(controller: controller);
final plant = GrowthObject(
stage: growthStage,
size: stack ? 116 : 122,
);
if (stack) {
return Column(
children: [plant, const SizedBox(height: 4), details],
);
}
return Row(
children: [
Expanded(child: details),
const SizedBox(width: 8),
plant,
],
);
},
),
const SizedBox(height: 14),
if (!controller.isRunning && !controller.isCompleted)
SizedBox(
width: double.infinity,
child: SegmentedButton<int>(
segments: [
for (final minutes in FocusTimerController.presets)
ButtonSegment(value: minutes, label: Text('$minutes')),
],
selected: {controller.selectedMinutes},
onSelectionChanged: (selection) {
HapticFeedback.selectionClick();
controller.selectMinutes(selection.first);
},
),
),
if (controller.isRunning || controller.isPaused) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: controller.progress,
minHeight: 7,
),
),
],
const SizedBox(height: 14),
_TimerActions(
controller: controller,
hasIntention: hasIntention,
onSetIntention: onSetIntention,
onReflect: onReflect,
),
],
),
);
},
);
}
}
class _TimerDetails extends StatelessWidget {
const _TimerDetails({required this.controller});
final FocusTimerController controller;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final status = switch (controller.status) {
FocusTimerStatus.idle => l10n.timerReady,
FocusTimerStatus.running => l10n.timerRunning,
FocusTimerStatus.paused => l10n.timerPaused,
FocusTimerStatus.completed => l10n.timerCompleted,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: Text(
formatSeconds(controller.remainingSeconds),
key: ValueKey(controller.remainingSeconds),
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 36,
fontWeight: FontWeight.w600,
height: 1,
),
),
),
const SizedBox(height: 8),
Row(
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
controller.isCompleted
? theme.colorScheme.tertiary
: theme.colorScheme.primary,
),
),
const SizedBox(width: 7),
Flexible(
child: Text(
status,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
);
}
}
class _TimerActions extends StatelessWidget {
const _TimerActions({
required this.controller,
required this.hasIntention,
required this.onSetIntention,
required this.onReflect,
});
final FocusTimerController controller;
final bool hasIntention;
final VoidCallback onSetIntention;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
if (controller.isCompleted) {
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: onReflect,
icon: const Icon(Icons.edit_outlined),
label: Text(l10n.reflectOneMinute),
),
),
const SizedBox(width: 8),
IconButton.outlined(
tooltip: l10n.startAnotherSession,
onPressed: controller.reset,
icon: const Icon(Icons.restart_alt_rounded),
),
],
);
}
if (!hasIntention && !controller.isRunning && !controller.isPaused) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onSetIntention,
icon: const Icon(Icons.flag_outlined),
label: Text(l10n.setTodaysIntention),
),
);
}
if (controller.isRunning) {
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: controller.pause,
icon: const Icon(Icons.pause_rounded),
label: Text(l10n.pause),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: controller.completeNow,
icon: const Icon(Icons.stop_rounded),
label: Text(l10n.end),
),
],
);
}
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () {
HapticFeedback.selectionClick();
controller.start();
},
icon: Icon(
controller.isPaused
? Icons.play_arrow_rounded
: Icons.play_circle_outline_rounded,
),
label: Text(
controller.isPaused
? l10n.resume
: l10n.startMinutes(controller.selectedMinutes),
),
),
),
if (controller.isPaused) ...[
const SizedBox(width: 8),
IconButton.outlined(
tooltip: l10n.resetTimer,
onPressed: controller.reset,
icon: const Icon(Icons.restart_alt_rounded),
),
],
],
);
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart';
import '../../../models/habit.dart';
import '../../../widgets/surface_panel.dart';
@@ -24,10 +25,16 @@ class HabitSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final visuals = FocusSeedVisuals.of(context);
final completed =
habits.where((habit) => habit.completedDates.contains(dateKey)).length;
final completion =
habits.isEmpty ? 0.0 : (completed / habits.length).clamp(0.0, 1.0);
return SurfacePanel(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 8),
tone: SurfacePanelTone.paper,
padding: const EdgeInsets.fromLTRB(16, 14, 14, 10),
child: Column(
children: [
Row(
@@ -38,12 +45,14 @@ class HabitSection extends StatelessWidget {
children: [
Text(
l10n.todaysHabits,
style: Theme.of(context).textTheme.titleMedium,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
Text(
l10n.completedCount(completed, habits.length),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
@@ -61,16 +70,15 @@ class HabitSection extends StatelessWidget {
),
],
),
const SizedBox(height: 4),
const SizedBox(height: 12),
_HabitCompletionTrack(progress: completion),
const SizedBox(height: 6),
if (habits.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 20),
child: Row(
children: [
Icon(
Icons.eco_outlined,
color: Theme.of(context).colorScheme.primary,
),
Icon(Icons.eco_outlined, color: visuals.habit),
const SizedBox(width: 10),
Expanded(child: Text(l10n.emptyHabitPrompt)),
TextButton(onPressed: onAdd, child: Text(l10n.add)),
@@ -79,7 +87,7 @@ class HabitSection extends StatelessWidget {
)
else
for (var index = 0; index < habits.take(4).length; index++) ...[
if (index > 0) const Divider(),
if (index > 0) const Divider(indent: 48),
CompactHabitRow(
habit: habits[index],
dateKey: dateKey,
@@ -87,7 +95,7 @@ class HabitSection extends StatelessWidget {
),
],
if (habits.length > 4) ...[
const Divider(),
const Divider(indent: 48),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
@@ -102,3 +110,48 @@ class HabitSection extends StatelessWidget {
);
}
}
class _HabitCompletionTrack extends StatelessWidget {
const _HabitCompletionTrack({required this.progress});
final double progress;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final visuals = FocusSeedVisuals.of(context);
return ClipRRect(
borderRadius: BorderRadius.circular(99),
child: SizedBox(
height: 5,
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: scheme.outlineVariant.withValues(alpha: 0.46)),
TweenAnimationBuilder<double>(
tween: Tween(end: progress),
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: value,
heightFactor: 1,
child: child,
),
);
},
child: DecoratedBox(
decoration: BoxDecoration(
color: visuals.success,
borderRadius: BorderRadius.circular(99),
),
),
),
],
),
),
);
}
}
@@ -1,75 +1,198 @@
import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart';
import '../../../widgets/surface_panel.dart';
class IntentionCard extends StatelessWidget {
const IntentionCard({
required this.intention,
required this.progress,
required this.onEdit,
super.key,
});
final String intention;
final TodayProgress progress;
final VoidCallback onEdit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
final l10n = context.l10n;
final isEmpty = intention.isEmpty;
final body =
isEmpty
? l10n.todayLoopStartBody
: progress.completedSteps >= 3
? l10n.todayLoopDoneBody
: l10n.todayLoopActiveBody;
return SurfacePanel(
elevated: !isEmpty,
tone: SurfacePanelTone.paper,
onTap: onEdit,
padding: const EdgeInsets.all(14),
child: Row(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 15),
color: Color.alphaBlend(
visuals.action.withValues(alpha: isEmpty ? 0.035 : 0.025),
theme.colorScheme.surfaceContainerLowest,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.11),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.flag_outlined, color: theme.colorScheme.primary),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.todayIntentionLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 5,
height: 58,
decoration: BoxDecoration(
color: visuals.action,
borderRadius: BorderRadius.circular(99),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.24),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
const SizedBox(height: 5),
Text(
isEmpty ? l10n.chooseOneOutcome : intention,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontSize: 18,
color:
isEmpty
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.todayIntentionLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: visuals.action,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 5),
Text(
isEmpty ? l10n.chooseOneOutcome : intention,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
height: 1.08,
color:
isEmpty
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
),
),
],
),
),
],
),
const SizedBox(height: 10),
Text(
body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (isEmpty) ...[
const SizedBox(height: 14),
FilledButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.flag_outlined),
label: Text(l10n.setTodaysIntention),
),
],
if (!isEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_StatusChip(
icon: Icons.check_circle_outline_rounded,
label: l10n.todayLoopProgress(progress.completedSteps),
color: visuals.success,
),
_StatusChip(
icon: Icons.timer_outlined,
label: l10n.minutesShort(
progress.focusMinutes == 0 ? 10 : progress.focusMinutes,
),
color: visuals.focus,
),
_StatusChip(
icon: Icons.edit_note_rounded,
label:
progress.hasReflection
? l10n.reflectionCaptured
: l10n.reflectionAction,
color: visuals.reflection,
),
],
),
),
const SizedBox(width: 8),
Icon(
isEmpty ? Icons.add_rounded : Icons.edit_outlined,
color: theme.colorScheme.onSurfaceVariant,
size: 21,
),
],
],
),
);
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({
required this.icon,
required this.label,
required this.color,
});
final IconData icon;
final String label;
final Color color;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 178),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w900,
color: theme.colorScheme.onSurface,
),
),
),
],
),
),
),
);
}
}
@@ -22,7 +22,7 @@ class ReflectionPrompt extends StatelessWidget {
final visuals = FocusSeedVisuals.of(context);
final isComplete = reflection != null;
return SurfacePanel(
borderColor: visuals.reflection.withValues(alpha: 0.35),
tone: SurfacePanelTone.paper,
color: visuals.reflection.withValues(alpha: 0.06),
onTap: onTap,
child: Row(
+125 -57
View File
@@ -5,11 +5,12 @@
"saving": "Saving",
"local": "Local",
"navToday": "Today",
"navJourney": "Journey",
"navRecords": "Records",
"navMe": "Me",
"tryAgain": "Try again",
"couldNotOpenApp": "Could not open Focus Seed.",
"openFailed": "Your local garden could not be opened.",
"loadingLocalData": "Loading local data",
"openFailed": "Your local data could not be opened.",
"saveFailed": "That change could not be saved. Please try again.",
"goodMorning": "Good morning",
"goodDay": "Good day",
@@ -33,11 +34,23 @@
"timerRunning": "Deep work in progress",
"timerPaused": "Paused - your place is kept",
"timerCompleted": "Session complete",
"reflectOneMinute": "Reflect for 1 min",
"focusCurrentIntention": "Today's focus: {intention}",
"@focusCurrentIntention": {
"placeholders": {
"intention": {
"type": "String"
}
}
},
"focusNoIntentionTitle": "Start this session first",
"reflectOneMinute": "Write progress note",
"startFocus": "Start focus",
"startAnotherSession": "Start another session",
"setTodaysIntention": "Set today's focus",
"pause": "Pause",
"end": "End",
"completeSession": "Complete session",
"continueSession": "Continue session",
"resume": "Resume",
"startMinutes": "Start {minutes} min",
"@startMinutes": {
@@ -63,7 +76,7 @@
"manageHabits": "Manage habits",
"addHabit": "Add habit",
"add": "Add",
"emptyHabitPrompt": "Add one repeatable habit.",
"emptyHabitPrompt": "Keep this optional and small.",
"viewAllHabits": "View all {count}",
"@viewAllHabits": {
"placeholders": {
@@ -90,7 +103,7 @@
}
}
},
"dailyStepsComplete": "{completed}/4 daily steps complete",
"dailyStepsComplete": "{completed}/3 daily steps complete",
"@dailyStepsComplete": {
"placeholders": {
"completed": {
@@ -101,16 +114,17 @@
"habitOptions": "Habit options",
"rename": "Rename",
"delete": "Delete",
"habitsTitle": "Habits",
"habitsSubtitle": "Keep it small and repeatable.",
"emptyHabitsTitle": "Add a small habit",
"habitsTitle": "Supporting habits",
"habitsSubtitle": "Optional repeatable steps. Today's focus still comes first.",
"emptyHabitsTitle": "Add a supporting habit",
"addHabitTitle": "Add habit",
"renameHabitTitle": "Rename habit",
"habitHint": "Example: Read 10 pages",
"cancel": "Cancel",
"save": "Save",
"setIntentionTitle": "Set intention",
"editIntentionTitle": "Edit intention",
"textEntryRequired": "Write a short line first.",
"setIntentionTitle": "Set today's focus",
"editIntentionTitle": "Edit today's focus",
"intentionHint": "What matters most today?",
"deleteHabitTitle": "Delete habit?",
"deleteHabitBody": "Past completion records for this habit will be removed.",
@@ -124,20 +138,22 @@
"deleteAll": "Delete all",
"privacyTitle": "Privacy & data",
"privacyBodyOne": "Focus Seed stores your intention, habits, focus sessions, reflections, and settings locally on this device. This version has no sign-in, cloud sync, ads, analytics SDK, or billing SDK.",
"privacyBodyTwo": "You can clear today's progress or delete all local data from Me at any time.",
"privacyBodyTwo": "You can clear today's progress or delete all local data from settings at any time.",
"reflectionEmptyError": "Write one honest sentence.",
"reflectionSaveError": "Could not save. Please try again.",
"reflectionSheetTitle": "How did it go?",
"reflectionSheetSubtitle": "Keep what worked. Adjust one thing.",
"reflectionHint": "One useful sentence is enough.",
"reflectionSheetTitle": "Leave one progress note",
"reflectionSheetSubtitle": "Save what moved forward so next time has a clear start.",
"reflectionHint": "What moved forward?",
"reflectionMoodLabel": "Mood is optional",
"savingLabel": "Saving",
"saveReflection": "Save reflection",
"saveReflection": "Save note",
"moodCalm": "Calm",
"moodClear": "Clear",
"moodProud": "Proud",
"moodTired": "Tired",
"meTitle": "Your garden",
"meSubtitle": "Preferences and records stay on this device.",
"meTitle": "Me",
"meSubtitle": "Manage your rhythm, appearance, and records.",
"appSettingsTitle": "App settings",
"appearance": "Theme",
"system": "System",
"light": "Light",
@@ -148,14 +164,19 @@
"languageEnglish": "English",
"languageChinese": "Chinese",
"preferencesTitle": "Preferences",
"preferencesDetail": "Make it feel right",
"preferencesDetail": "Theme, language, and default focus",
"defaultFocusDuration": "Default focus",
"defaultFocusDurationDetail": "Next session",
"localData": "Local records",
"localDataDetail": "Private on device",
"personalGardenTitle": "Your focus garden",
"personalGardenSubtitle": "Every small session leaves a trace you can return to.",
"personalGardenTitle": "My accumulation",
"personalGardenSubtitle": "Your local action records, progress notes, and app controls stay on this device.",
"focusDataLabel": "Focus",
"habitsDataLabel": "Habits",
"notesDataLabel": "Notes",
"notesDataLabel": "Progress",
"totalActionTimeLabel": "Action time",
"recordsCountLabel": "Records",
"progressNotesCountLabel": "Progress notes",
"clearToday": "Clear today",
"clearTodaySubtitle": "Reset only today's loop.",
"privacySubtitle": "No account or cloud sync.",
@@ -163,24 +184,22 @@
"deleteAllLocalDataSubtitle": "Remove this device's Focus Seed records.",
"todayDataCleared": "Today's data cleared",
"localDataCleared": "Local data deleted",
"journeyTitle": "Progress",
"journeyOverviewLabel": "TODAY'S LOOP",
"journeyOverviewStartTitle": "Start with one clear focus",
"journeyOverviewStartBody": "Pick the thing that would make today feel finished.",
"journeyOverviewActiveTitle": "Your day is taking shape",
"journeyOverviewActiveBody": "Keep the loop moving: focus, habits, then a short reflection.",
"journeyOverviewDoneTitle": "Loop complete",
"journeyOverviewDoneBody": "You left a clean record for future you.",
"journeyOverviewProgress": "{completed}/4 done",
"@journeyOverviewProgress": {
"todayLoopLabel": "TODAY'S LOOP",
"todayLoopStartTitle": "Start with one clear focus",
"todayLoopStartBody": "Pick the one result that matters today.",
"todayLoopActiveTitle": "Your day is taking shape",
"todayLoopActiveBody": "Focus once, then leave one useful note.",
"todayLoopDoneTitle": "Progress saved",
"todayLoopDoneBody": "You moved today forward and left a clear place to continue.",
"todayLoopProgress": "{completed}/3 done",
"@todayLoopProgress": {
"placeholders": {
"completed": {
"type": "int"
}
}
},
"reviewNow": "Reflect now",
"journeyStatsTitle": "7-day rhythm",
"reviewNow": "Write a note",
"weekFocusLabel": "Focus",
"streakLabel": "Streak",
"activeDaysLabel": "Active",
@@ -207,9 +226,28 @@
"focusHistory": "Focus",
"notesHistory": "Notes",
"emptyFocusHistoryTitle": "No focus sessions yet",
"emptyFocusHistoryBody": "Start one session from Today and it will appear here.",
"emptyFocusHistoryBody": "Finish one focus session and it will appear here.",
"emptyNotesHistoryTitle": "No notes yet",
"emptyNotesHistoryBody": "Save a short reflection after focus to make your progress visible.",
"emptyNotesHistoryBody": "Save one reflection after a session and it will appear here.",
"recordsEmptyTitle": "No progress records yet",
"recordsEmptyBody": "Finish one focus session and leave a note to keep today's progress here.",
"recordsCalendarTitle": "Activity dates",
"recordsDateLedgerTitle": "Date ledger",
"backToToday": "Back to Today",
"recordsNoIntention": "Unnamed daily focus",
"recordsNoReflection": "No progress note yet",
"recordsMissingProgressHelper": "Add one line so this is easier to pick up later.",
"addProgressNote": "Add progress note",
"continueThisAction": "Continue this",
"replaceTodayActionTitle": "Replace today's action?",
"replaceTodayActionBody": "This will change today's action to \"{actionTitle}\". Existing records will stay.",
"@replaceTodayActionBody": {
"placeholders": {
"actionTitle": {
"type": "String"
}
}
},
"recentFocus": "Recent focus",
"sessionsTotal": "{count} sessions total",
"@sessionsTotal": {
@@ -250,32 +288,62 @@
}
},
"reflectionCaptured": "Today's lesson is captured",
"reflectionAction": "Close the loop with one sentence",
"growthTitle": "Growth",
"growthFlowerOpen": "Today's flower is open",
"growthLocked": "Unlocked after reflection",
"sevenDayGrowth": "7-day growth",
"sevenDayGrowthSubtitle": "Small actions become visible over time.",
"growthSeed": "seed",
"growthSprout": "sprout",
"growthLeaves": "leaves",
"growthFlower": "flower",
"growthDaySemantic": "{date}, {stage}",
"@growthDaySemantic": {
"reflectionAction": "Write one sentence to close today",
"nextActionTitle": "Next action",
"nextActionSetFocusTitle": "Write today's focus",
"nextActionSetFocusBody": "One short line is enough. You can adjust it later.",
"nextActionStartFocusTitle": "Start 10 minutes",
"nextActionStartFocusBody": "Writing the action is optional. Start first.",
"nextActionStartActionBody": "Move this forward in one small session, then leave one progress note.",
"nextActionHabitsTitle": "Finish one small habit",
"nextActionHabitsBody": "Complete one repeatable step while it is still easy.",
"nextActionReflectTitle": "Write a quick note",
"nextActionReflectBody": "Record what moved forward so the next start is easier.",
"nextActionCompleteTitle": "Today moved forward",
"nextActionCompleteBody": "Your note is saved. Continue only if it still feels useful.",
"nextActionContinueFocus": "Focus {minutes} min again",
"@nextActionContinueFocus": {
"placeholders": {
"date": {
"type": "Object"
},
"stage": {
"type": "Object"
"minutes": {
"type": "int"
}
}
},
"growthSeedSemantic": "A seed waiting for today's intention",
"growthSproutSemantic": "A sprout growing from today's intention",
"growthLeavesSemantic": "A young plant growing with today's progress",
"growthFlowerSemantic": "A flower marking a complete daily cycle",
"habitTemplatesTitle": "Useful starts",
"habitTemplatesBody": "Add one tiny repeatable action.",
"habitTemplateStretch": "2 min stretch",
"habitTemplateRead": "Read 10 pages",
"habitTemplateWalk": "Walk outside",
"habitTemplateNoScroll": "No-scroll lunch",
"languageDetail": "Interface language",
"recordsAndPrivacyTitle": "Records & privacy",
"recordsAndPrivacyBody": "Your history stays local. Keep, reset, or delete it when needed.",
"welcomeTrustLabel": "TODAY",
"welcomeTitle": "Plant just one thing each day.",
"welcomeBody": "Start with today's most important thing.",
"welcomeNext": "The next step starts here.",
"onboardingFocusBadge": "FOCUS",
"onboardingFocusTitle": "Protect 10 minutes first.",
"onboardingFocusBody": "It does not have to be long. Starting creates progress.",
"onboardingFocusNext": "Keep this one thing in front of you while you focus.",
"onboardingReflectBadge": "NOTE",
"onboardingReflectTitle": "Leave one progress note.",
"onboardingReflectBody": "When you finish, write one line so you know where to continue next time.",
"onboardingReflectNext": "Now set today's focus.",
"onboardingNext": "Continue",
"welcomeStart": "Set today's focus",
"welcomeSkip": "Skip",
"landingTitle": "Start with 10 minutes.",
"landingBody": "Writing is optional. Start first.",
"landingFocusLabel": "What do you want to do first?",
"landingFocusEntry": "Write down what you will do",
"landingFocusHint": "Example: finish the launch checklist",
"customFocusDuration": "Custom length",
"customFocusDurationHint": "Enter 1-180 minutes",
"minutesInputLabel": "Minutes",
"landingStart": "Start 10 minutes",
"landingSkip": "Enter Today",
"defaultHabitPlanTask": "Plan one clear task",
"defaultHabitFocus": "Focus for 25 minutes",
"defaultHabitFocus": "Focus for 10 minutes",
"defaultHabitReflect": "Write one-line reflection"
}
+238 -170
View File
@@ -2,19 +2,20 @@
"@@locale": "zh",
"appTitle": "Focus Seed",
"brandName": "Focus Seed",
"saving": "\u4fdd\u5b58\u4e2d",
"local": "\u672c\u5730",
"navToday": "\u4eca\u65e5",
"navJourney": "\u8fdb\u5c55",
"navMe": "\u6211\u7684",
"tryAgain": "\u91cd\u8bd5",
"couldNotOpenApp": "\u65e0\u6cd5\u6253\u5f00 Focus Seed\u3002",
"openFailed": "\u672c\u5730\u6570\u636e\u6253\u5f00\u5931\u8d25\u3002",
"saveFailed": "\u4fdd\u5b58\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002",
"goodMorning": "\u65e9\u4e0a\u597d",
"goodDay": "\u4eca\u5929\u597d",
"goodEvening": "\u665a\u4e0a\u597d",
"goodNight": "\u591c\u6df1\u4e86",
"saving": "保存中",
"local": "本地",
"navToday": "今日",
"navRecords": "记录",
"navMe": "我的",
"tryAgain": "重试",
"couldNotOpenApp": "无法打开 Focus Seed。",
"loadingLocalData": "正在读取本地数据",
"openFailed": "本地数据打开失败。",
"saveFailed": "保存失败,请重试。",
"goodMorning": "早上好",
"goodDay": "今天好",
"goodEvening": "晚上好",
"goodNight": "夜深了",
"readableDate": "{date} - {weekday}",
"@readableDate": {
"placeholders": {
@@ -26,20 +27,32 @@
}
}
},
"todayIntentionLabel": "\u4eca\u65e5\u91cd\u70b9",
"chooseOneOutcome": "\u5199\u4e0b\u4eca\u5929\u7684\u91cd\u70b9",
"focusLabel": "\u4e13\u6ce8",
"timerReady": "\u51c6\u5907\u597d\u4e86\u5c31\u5f00\u59cb",
"timerRunning": "\u4e13\u6ce8\u4e2d",
"timerPaused": "\u5df2\u6682\u505c",
"timerCompleted": "\u5df2\u5b8c\u6210",
"reflectOneMinute": "\u590d\u76d8 1 \u5206\u949f",
"startAnotherSession": "\u518d\u6765\u4e00\u8f6e",
"setTodaysIntention": "\u8bbe\u7f6e\u4eca\u65e5\u91cd\u70b9",
"pause": "\u6682\u505c",
"end": "\u7ed3\u675f",
"resume": "\u7ee7\u7eed",
"startMinutes": "\u5f00\u59cb {minutes} \u5206\u949f",
"todayIntentionLabel": "今日重点",
"chooseOneOutcome": "写下今天的重点",
"focusLabel": "专注",
"timerReady": "准备好了就开始",
"timerRunning": "专注中",
"timerPaused": "已暂停",
"timerCompleted": "已完成",
"focusCurrentIntention": "今日重点:{intention}",
"@focusCurrentIntention": {
"placeholders": {
"intention": {
"type": "String"
}
}
},
"focusNoIntentionTitle": "先开始这一段",
"reflectOneMinute": "写一句进展",
"startFocus": "开始专注",
"startAnotherSession": "再来一轮",
"setTodaysIntention": "设置今日重点",
"pause": "暂停",
"end": "结束",
"completeSession": "完成本轮",
"continueSession": "继续本轮",
"resume": "继续",
"startMinutes": "开始 {minutes} 分钟",
"@startMinutes": {
"placeholders": {
"minutes": {
@@ -47,9 +60,9 @@
}
}
},
"resetTimer": "\u91cd\u7f6e\u8ba1\u65f6",
"todaysHabits": "\u4eca\u65e5\u4e60\u60ef",
"completedCount": "{completed}/{total} \u5b8c\u6210",
"resetTimer": "重置计时",
"todaysHabits": "今日习惯",
"completedCount": "{completed}/{total} 完成",
"@completedCount": {
"placeholders": {
"completed": {
@@ -60,11 +73,11 @@
}
}
},
"manageHabits": "\u7ba1\u7406\u4e60\u60ef",
"addHabit": "\u6dfb\u52a0\u4e60\u60ef",
"add": "\u6dfb\u52a0",
"emptyHabitPrompt": "\u6dfb\u52a0\u4e00\u4e2a\u53ef\u91cd\u590d\u7684\u5c0f\u4e60\u60ef\u3002",
"viewAllHabits": "\u67e5\u770b\u5168\u90e8 {count} \u4e2a",
"manageHabits": "管理习惯",
"addHabit": "添加习惯",
"add": "添加",
"emptyHabitPrompt": "保持可选,也保持很小。",
"viewAllHabits": "查看全部 {count} 个",
"@viewAllHabits": {
"placeholders": {
"count": {
@@ -72,17 +85,17 @@
}
}
},
"reflectionSaved": "\u590d\u76d8\u5df2\u4fdd\u5b58",
"reflectionPromptTitle": "\u590d\u76d8\u4e00\u4e0b",
"reflectionPromptBody": "\u5199\u4e00\u53e5\u6709\u7528\u7684\u603b\u7ed3\u3002",
"progressIntention": "\u76ee\u6807",
"progressFocus": "\u4e13\u6ce8",
"progressHabits": "\u4e60\u60ef",
"progressReflect": "\u590d\u76d8",
"done": "\u5b8c\u6210",
"next": "\u4e0b\u4e00\u6b65",
"later": "\u7a0d\u540e",
"minutesShort": "{minutes} \u5206\u949f",
"reflectionSaved": "复盘已保存",
"reflectionPromptTitle": "复盘一下",
"reflectionPromptBody": "写一句有用的总结。",
"progressIntention": "目标",
"progressFocus": "专注",
"progressHabits": "习惯",
"progressReflect": "复盘",
"done": "完成",
"next": "下一步",
"later": "稍后",
"minutesShort": "{minutes} 分钟",
"@minutesShort": {
"placeholders": {
"minutes": {
@@ -90,7 +103,7 @@
}
}
},
"dailyStepsComplete": "\u4eca\u65e5\u5b8c\u6210 {completed}/4",
"dailyStepsComplete": "今日完成 {completed}/3",
"@dailyStepsComplete": {
"placeholders": {
"completed": {
@@ -98,94 +111,100 @@
}
}
},
"habitOptions": "\u4e60\u60ef\u9009\u9879",
"rename": "\u91cd\u547d\u540d",
"delete": "\u5220\u9664",
"habitsTitle": "\u4e60\u60ef",
"habitsSubtitle": "\u5c11\u91cf\u3001\u53ef\u91cd\u590d\u3002",
"emptyHabitsTitle": "\u6dfb\u52a0\u5c0f\u4e60\u60ef",
"addHabitTitle": "\u6dfb\u52a0\u4e60\u60ef",
"renameHabitTitle": "\u91cd\u547d\u540d\u4e60\u60ef",
"habitHint": "\u4f8b\u5982\uff1a\u8bfb 10 \u9875\u4e66",
"cancel": "\u53d6\u6d88",
"save": "\u4fdd\u5b58",
"setIntentionTitle": "\u8bbe\u7f6e\u76ee\u6807",
"editIntentionTitle": "\u7f16\u8f91\u76ee\u6807",
"intentionHint": "\u4eca\u5929\u6700\u91cd\u8981\u7684\u662f\u4ec0\u4e48\uff1f",
"deleteHabitTitle": "\u5220\u9664\u4e60\u60ef\uff1f",
"deleteHabitBody": "\u8fd9\u4e2a\u4e60\u60ef\u7684\u5386\u53f2\u5b8c\u6210\u8bb0\u5f55\u4e5f\u4f1a\u5220\u9664\u3002",
"close": "\u5173\u95ed",
"copy": "\u590d\u5236",
"clearTodayDataTitle": "\u6e05\u7406\u4eca\u65e5\u6570\u636e\uff1f",
"clearTodayDataBody": "\u53ea\u91cd\u7f6e\u4eca\u5929\u7684\u91cd\u70b9\u3001\u6253\u5361\u3001\u4e13\u6ce8\u8bb0\u5f55\u548c\u590d\u76d8\uff0c\u8fc7\u53bb\u65e5\u671f\u7684\u6570\u636e\u4f1a\u4fdd\u7559\u3002",
"clearAllDataTitle": "\u5220\u9664\u5168\u90e8\u672c\u5730\u6570\u636e\uff1f",
"clearAllDataBody": "\u4f1a\u5220\u9664\u672c\u673a\u7684\u76ee\u6807\u3001\u4e60\u60ef\u3001\u4e13\u6ce8\u8bb0\u5f55\u548c\u590d\u76d8\uff0c\u65e0\u6cd5\u64a4\u9500\u3002",
"clear": "\u6e05\u9664",
"deleteAll": "\u5168\u90e8\u5220\u9664",
"privacyTitle": "\u9690\u79c1\u4e0e\u6570\u636e",
"privacyBodyOne": "Focus Seed \u53ea\u5728\u672c\u673a\u4fdd\u5b58\u76ee\u6807\u3001\u4e60\u60ef\u3001\u4e13\u6ce8\u8bb0\u5f55\u3001\u590d\u76d8\u548c\u8bbe\u7f6e\u3002\u5f53\u524d\u7248\u672c\u6ca1\u6709\u767b\u5f55\u3001\u4e91\u540c\u6b65\u3001\u5e7f\u544a\u3001\u5206\u6790 SDK \u6216\u4ed8\u8d39 SDK\u3002",
"privacyBodyTwo": "\u4f60\u53ef\u4ee5\u968f\u65f6\u6e05\u7406\u4eca\u65e5\u8fdb\u5ea6\uff0c\u6216\u5220\u9664\u672c\u673a\u7684\u5168\u90e8\u672c\u5730\u6570\u636e\u3002",
"reflectionEmptyError": "\u5199\u4e00\u53e5\u771f\u5b9e\u7684\u603b\u7ed3\u3002",
"reflectionSaveError": "\u4fdd\u5b58\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002",
"reflectionSheetTitle": "\u590d\u76d8\u4e00\u4e0b",
"reflectionSheetSubtitle": "\u4fdd\u7559\u6709\u6548\u7684\uff0c\u8c03\u6574\u4e00\u4e2a\u70b9\u3002",
"reflectionHint": "\u4e00\u53e5\u6709\u7528\u7684\u8bdd\u5c31\u591f\u3002",
"savingLabel": "\u4fdd\u5b58\u4e2d",
"saveReflection": "\u4fdd\u5b58\u590d\u76d8",
"moodCalm": "\u5e73\u9759",
"moodClear": "\u6e05\u9192",
"moodProud": "\u6ee1\u610f",
"moodTired": "\u75b2\u60eb",
"meTitle": "\u6211\u7684\u82b1\u56ed",
"meSubtitle": "\u504f\u597d\u548c\u8bb0\u5f55\u90fd\u53ea\u4fdd\u5b58\u5728\u672c\u673a\u3002",
"appearance": "\u4e3b\u9898",
"system": "\u7cfb\u7edf",
"light": "\u6d45\u8272",
"dark": "\u6df1\u8272",
"language": "\u8bed\u8a00",
"developmentOnly": "\u4ec5\u5f00\u53d1\u73af\u5883",
"languageDefault": "\u9ed8\u8ba4",
"habitOptions": "习惯选项",
"rename": "重命名",
"delete": "删除",
"habitsTitle": "辅助习惯",
"habitsSubtitle": "可选的重复动作,今日重点仍然放在前面。",
"emptyHabitsTitle": "添加一个辅助习惯",
"addHabitTitle": "添加习惯",
"renameHabitTitle": "重命名习惯",
"habitHint": "例如:读 10 页书",
"cancel": "取消",
"save": "保存",
"textEntryRequired": "先写一个短句。",
"setIntentionTitle": "设置今日重点",
"editIntentionTitle": "编辑今日重点",
"intentionHint": "今天最重要的是什么?",
"deleteHabitTitle": "删除习惯?",
"deleteHabitBody": "这个习惯的历史完成记录也会删除。",
"close": "关闭",
"copy": "复制",
"clearTodayDataTitle": "清理今日数据?",
"clearTodayDataBody": "只重置今天的重点、打卡、专注记录和复盘,过去日期的数据会保留。",
"clearAllDataTitle": "删除全部本地数据?",
"clearAllDataBody": "会删除本机的目标、习惯、专注记录和复盘,无法撤销。",
"clear": "清除",
"deleteAll": "全部删除",
"privacyTitle": "隐私与数据",
"privacyBodyOne": "Focus Seed 只在本机保存目标、习惯、专注记录、复盘和设置。当前版本没有登录、云同步、广告、分析 SDK 或付费 SDK。",
"privacyBodyTwo": "你可以随时在设置中清理今日进度,或删除本机的全部本地数据。",
"reflectionEmptyError": "写一句真实的总结。",
"reflectionSaveError": "保存失败,请重试。",
"reflectionSheetTitle": "留下一句进展",
"reflectionSheetSubtitle": "记下刚推进了什么,下次就知道从哪里继续。",
"reflectionHint": "刚才推进了什么?",
"reflectionMoodLabel": "状态可以稍后补",
"savingLabel": "保存中",
"saveReflection": "保存记录",
"moodCalm": "平静",
"moodClear": "清醒",
"moodProud": "满意",
"moodTired": "疲惫",
"meTitle": "我的",
"meSubtitle": "管理你的节奏、外观和记录。",
"appSettingsTitle": "应用设置",
"appearance": "主题",
"system": "系统",
"light": "浅色",
"dark": "深色",
"language": "语言",
"developmentOnly": "仅开发环境",
"languageDefault": "默认",
"languageEnglish": "English",
"languageChinese": "\u4e2d\u6587",
"preferencesTitle": "\u504f\u597d",
"preferencesDetail": "\u8ba9\u754c\u9762\u66f4\u987a\u624b",
"localData": "\u672c\u5730\u8bb0\u5f55",
"localDataDetail": "\u53ea\u5728\u672c\u673a",
"personalGardenTitle": "\u4f60\u7684\u4e13\u6ce8\u82b1\u56ed",
"personalGardenSubtitle": "\u6bcf\u4e00\u6b21\u5c0f\u4e13\u6ce8\uff0c\u90fd\u4f1a\u7559\u4e0b\u53ef\u56de\u770b\u7684\u75d5\u8ff9\u3002",
"focusDataLabel": "\u4e13\u6ce8",
"habitsDataLabel": "\u4e60\u60ef",
"notesDataLabel": "\u8bb0\u5f55",
"clearToday": "\u6e05\u7406\u4eca\u5929",
"clearTodaySubtitle": "\u53ea\u91cd\u7f6e\u4eca\u5929\u7684\u95ed\u73af\u3002",
"privacySubtitle": "\u65e0\u8d26\u53f7\uff0c\u65e0\u4e91\u540c\u6b65\u3002",
"deleteAllLocalData": "\u5220\u9664\u5168\u90e8\u6570\u636e",
"deleteAllLocalDataSubtitle": "\u79fb\u9664\u8fd9\u53f0\u8bbe\u5907\u4e0a\u7684 Focus Seed \u8bb0\u5f55\u3002",
"todayDataCleared": "\u4eca\u65e5\u6570\u636e\u5df2\u6e05\u7406",
"localDataCleared": "\u672c\u5730\u6570\u636e\u5df2\u5220\u9664",
"journeyTitle": "\u8fdb\u5c55",
"journeyOverviewLabel": "\u4eca\u65e5\u95ed\u73af",
"journeyOverviewStartTitle": "\u5148\u5b9a\u4e0b\u4e00\u4e2a\u91cd\u70b9",
"journeyOverviewStartBody": "\u5199\u4e0b\u4eca\u5929\u6700\u503c\u5f97\u5b8c\u6210\u7684\u90a3\u4ef6\u4e8b\u3002",
"journeyOverviewActiveTitle": "\u4eca\u5929\u6b63\u5728\u6210\u5f62",
"journeyOverviewActiveBody": "\u7ee7\u7eed\u63a8\u8fdb\uff1a\u4e13\u6ce8\u3001\u6253\u5361\uff0c\u7136\u540e\u7528\u4e00\u53e5\u8bdd\u590d\u76d8\u3002",
"journeyOverviewDoneTitle": "\u4eca\u65e5\u95ed\u73af\u5b8c\u6210",
"journeyOverviewDoneBody": "\u4f60\u5df2\u7ecf\u7ed9\u672a\u6765\u7684\u81ea\u5df1\u7559\u4e0b\u4e86\u4e00\u6761\u6e05\u6670\u8bb0\u5f55\u3002",
"journeyOverviewProgress": "\u5df2\u5b8c\u6210 {completed}/4",
"@journeyOverviewProgress": {
"languageChinese": "中文",
"preferencesTitle": "偏好",
"preferencesDetail": "主题、语言和默认专注",
"defaultFocusDuration": "默认专注",
"defaultFocusDurationDetail": "下一轮",
"localData": "本地记录",
"localDataDetail": "只在本机",
"personalGardenTitle": "我的累计",
"personalGardenSubtitle": "你的本地行动记录、进展和应用控制都保留在这台设备上。",
"focusDataLabel": "专注",
"habitsDataLabel": "习惯",
"notesDataLabel": "进展",
"totalActionTimeLabel": "行动时间",
"recordsCountLabel": "记录",
"progressNotesCountLabel": "进展",
"clearToday": "清理今天",
"clearTodaySubtitle": "只重置今天的闭环。",
"privacySubtitle": "无账号,无云同步。",
"deleteAllLocalData": "删除全部数据",
"deleteAllLocalDataSubtitle": "移除这台设备上的 Focus Seed 记录。",
"todayDataCleared": "今日数据已清理",
"localDataCleared": "本地数据已删除",
"todayLoopLabel": "今日闭环",
"todayLoopStartTitle": "先定下一个重点",
"todayLoopStartBody": "先写下今天最重要的一件事。",
"todayLoopActiveTitle": "今天正在成形",
"todayLoopActiveBody": "先完成一轮专注,再留一句有用的记录。",
"todayLoopDoneTitle": "进展已保存",
"todayLoopDoneBody": "今天已经推进了一点,下次也知道从哪里继续。",
"todayLoopProgress": "已完成 {completed}/3",
"@todayLoopProgress": {
"placeholders": {
"completed": {
"type": "int"
}
}
},
"reviewNow": "\u73b0\u5728\u590d\u76d8",
"journeyStatsTitle": "7 \u65e5\u8282\u594f",
"weekFocusLabel": "\u4e13\u6ce8",
"streakLabel": "\u8fde\u7eed",
"activeDaysLabel": "\u6d3b\u8dc3",
"habitRateLabel": "\u4e60\u60ef",
"daysValue": "{count}\u5929",
"reviewNow": "写一句记录",
"weekFocusLabel": "专注",
"streakLabel": "连续",
"activeDaysLabel": "活跃",
"habitRateLabel": "习惯",
"daysValue": "{count}天",
"@daysValue": {
"placeholders": {
"count": {
@@ -201,17 +220,36 @@
}
}
},
"viewHistory": "\u8bb0\u5f55\u5e93",
"viewHistorySubtitle": "\u67e5\u770b\u4e13\u6ce8\u8bb0\u5f55\u548c\u590d\u76d8\u3002",
"historyTitle": "\u5386\u53f2",
"focusHistory": "\u4e13\u6ce8",
"notesHistory": "\u590d\u76d8",
"emptyFocusHistoryTitle": "\u8fd8\u6ca1\u6709\u4e13\u6ce8\u8bb0\u5f55",
"emptyFocusHistoryBody": "\u4ece\u4eca\u65e5\u9875\u5f00\u59cb\u4e00\u8f6e\u4e13\u6ce8\uff0c\u5b8c\u6210\u540e\u4f1a\u51fa\u73b0\u5728\u8fd9\u91cc\u3002",
"emptyNotesHistoryTitle": "\u8fd8\u6ca1\u6709\u590d\u76d8",
"emptyNotesHistoryBody": "\u4e13\u6ce8\u540e\u4fdd\u5b58\u4e00\u53e5\u590d\u76d8\uff0c\u8ba9\u8fdb\u5c55\u66f4\u6e05\u6670\u3002",
"recentFocus": "\u6700\u8fd1\u4e13\u6ce8",
"sessionsTotal": "\u5171 {count} \u6b21",
"viewHistory": "记录库",
"viewHistorySubtitle": "查看专注记录和复盘。",
"historyTitle": "历史",
"focusHistory": "专注",
"notesHistory": "复盘",
"emptyFocusHistoryTitle": "还没有专注记录",
"emptyFocusHistoryBody": "完成一轮专注后,记录会出现在这里。",
"emptyNotesHistoryTitle": "还没有复盘",
"emptyNotesHistoryBody": "专注后留一句复盘,这里会保留它。",
"recordsEmptyTitle": "还没有进展记录",
"recordsEmptyBody": "完成一次专注,再留一句记录,这里就会保留今天的进展。",
"recordsCalendarTitle": "活动日期",
"recordsDateLedgerTitle": "当日账本",
"backToToday": "回到今日",
"recordsNoIntention": "未命名今日重点",
"recordsNoReflection": "还没有进展记录",
"recordsMissingProgressHelper": "补一句,之后更容易接上。",
"addProgressNote": "补一句进展",
"continueThisAction": "继续这件事",
"replaceTodayActionTitle": "替换今天的行动?",
"replaceTodayActionBody": "会把今天的行动改为“{actionTitle}”,已有记录不会删除。",
"@replaceTodayActionBody": {
"placeholders": {
"actionTitle": {
"type": "String"
}
}
},
"recentFocus": "最近专注",
"sessionsTotal": "共 {count} 次",
"@sessionsTotal": {
"placeholders": {
"count": {
@@ -219,7 +257,7 @@
}
}
},
"focusMinutes": "\u4e13\u6ce8 {minutes} \u5206\u949f",
"focusMinutes": "专注 {minutes} 分钟",
"@focusMinutes": {
"placeholders": {
"minutes": {
@@ -227,9 +265,9 @@
}
}
},
"recentReflections": "\u6700\u8fd1\u590d\u76d8",
"recentReflectionsDetail": "\u503c\u5f97\u4fdd\u7559\u7684\u8bb0\u5f55",
"focusedMinutes": "\u5df2\u4e13\u6ce8 {minutes} \u5206\u949f",
"recentReflections": "最近复盘",
"recentReflectionsDetail": "值得保留的记录",
"focusedMinutes": "已专注 {minutes} 分钟",
"@focusedMinutes": {
"placeholders": {
"minutes": {
@@ -237,8 +275,8 @@
}
}
},
"noFocusSessionYet": "\u8fd8\u6ca1\u6709\u5b8c\u6210\u4e13\u6ce8",
"habitsCompleted": "{completed}/{total} \u5b8c\u6210",
"noFocusSessionYet": "还没有完成专注",
"habitsCompleted": "{completed}/{total} 完成",
"@habitsCompleted": {
"placeholders": {
"completed": {
@@ -249,33 +287,63 @@
}
}
},
"reflectionCaptured": "\u4eca\u65e5\u590d\u76d8\u5df2\u8bb0\u5f55",
"reflectionAction": "\u7528\u4e00\u53e5\u8bdd\u6536\u5c3e",
"growthTitle": "\u6210\u957f",
"growthFlowerOpen": "\u4eca\u65e5\u82b1\u6735\u5df2\u5f00\u653e",
"growthLocked": "\u590d\u76d8\u540e\u89e3\u9501",
"sevenDayGrowth": "7 \u65e5\u6210\u957f",
"sevenDayGrowthSubtitle": "\u8fde\u7eed\u884c\u52a8\u4f1a\u88ab\u770b\u89c1\u3002",
"growthSeed": "\u79cd\u5b50",
"growthSprout": "\u53d1\u82bd",
"growthLeaves": "\u751f\u957f",
"growthFlower": "\u5f00\u82b1",
"growthDaySemantic": "{date}\uff0c{stage}",
"@growthDaySemantic": {
"reflectionCaptured": "今日复盘已记录",
"reflectionAction": "用一句话给今天收尾",
"nextActionTitle": "下一步",
"nextActionSetFocusTitle": "写下今天的重点",
"nextActionSetFocusBody": "一个短句就够了,之后还可以调整。",
"nextActionStartFocusTitle": "开始 10 分钟",
"nextActionStartFocusBody": "写不写都可以,先开始。",
"nextActionStartActionBody": "先推进一小段,完成后留一句进展。",
"nextActionHabitsTitle": "完成一个小习惯",
"nextActionHabitsBody": "趁着还顺手,先做掉一个可重复的小动作。",
"nextActionReflectTitle": "写一句记录",
"nextActionReflectBody": "记下刚才推进了什么,下次更容易继续。",
"nextActionCompleteTitle": "今天已经推进了一点",
"nextActionCompleteBody": "进展已经留下,想继续时再来一段。",
"nextActionContinueFocus": "再专注 {minutes} 分钟",
"@nextActionContinueFocus": {
"placeholders": {
"date": {
"type": "Object"
},
"stage": {
"type": "Object"
"minutes": {
"type": "int"
}
}
},
"growthSeedSemantic": "\u7b49\u5f85\u4eca\u65e5\u76ee\u6807\u7684\u79cd\u5b50",
"growthSproutSemantic": "\u76ee\u6807\u5df2\u79cd\u4e0b\uff0c\u6b63\u5728\u53d1\u82bd",
"growthLeavesSemantic": "\u4eca\u65e5\u8fdb\u5ea6\u8ba9\u690d\u7269\u751f\u957f",
"growthFlowerSemantic": "\u4eca\u65e5\u95ed\u73af\u5b8c\u6210\uff0c\u82b1\u6735\u5f00\u653e",
"defaultHabitPlanTask": "\u8ba1\u5212\u4e00\u4e2a\u660e\u786e\u4efb\u52a1",
"defaultHabitFocus": "\u4e13\u6ce8 25 \u5206\u949f",
"defaultHabitReflect": "\u5199\u4e00\u53e5\u590d\u76d8"
"habitTemplatesTitle": "可以直接开始",
"habitTemplatesBody": "添加一个很小但能重复的动作。",
"habitTemplateStretch": "2 分钟拉伸",
"habitTemplateRead": "读 10 页书",
"habitTemplateWalk": "出门走走",
"habitTemplateNoScroll": "午餐不刷手机",
"languageDetail": "界面语言",
"recordsAndPrivacyTitle": "记录与隐私",
"recordsAndPrivacyBody": "历史只保存在本机。你可以保留、重置或彻底删除。",
"welcomeTrustLabel": "今日",
"welcomeTitle": "每天只种一件事。",
"welcomeBody": "先选今天最重要的一件。",
"welcomeNext": "下一步会从这里开始。",
"onboardingFocusBadge": "专注",
"onboardingFocusTitle": "先守住 10 分钟。",
"onboardingFocusBody": "时间不用长,能开始就有进展。",
"onboardingFocusNext": "专注时,把这件事留在眼前。",
"onboardingReflectBadge": "记录",
"onboardingReflectTitle": "留下一句进展。",
"onboardingReflectBody": "结束时记一句,下次就知道从哪里继续。",
"onboardingReflectNext": "现在设置今天重点。",
"onboardingNext": "继续",
"welcomeStart": "设置今天重点",
"welcomeSkip": "跳过",
"landingTitle": "先开始 10 分钟。",
"landingBody": "写不写都可以,先开始。",
"landingFocusLabel": "今天想先做什么?",
"landingFocusEntry": "写下这次要做的事",
"landingFocusHint": "例如:完成上线检查清单",
"customFocusDuration": "自定义时长",
"customFocusDurationHint": "输入 1-180 分钟",
"minutesInputLabel": "分钟数",
"landingStart": "开始 10 分钟",
"landingSkip": "进入今日页",
"defaultHabitPlanTask": "计划一个明确任务",
"defaultHabitFocus": "专注 10 分钟",
"defaultHabitReflect": "写一句复盘"
}
+445 -103
View File
@@ -125,11 +125,11 @@ abstract class AppLocalizations {
/// **'Today'**
String get navToday;
/// No description provided for @navJourney.
/// No description provided for @navRecords.
///
/// In en, this message translates to:
/// **'Journey'**
String get navJourney;
/// **'Records'**
String get navRecords;
/// No description provided for @navMe.
///
@@ -149,10 +149,16 @@ abstract class AppLocalizations {
/// **'Could not open Focus Seed.'**
String get couldNotOpenApp;
/// No description provided for @loadingLocalData.
///
/// In en, this message translates to:
/// **'Loading local data'**
String get loadingLocalData;
/// No description provided for @openFailed.
///
/// In en, this message translates to:
/// **'Your local garden could not be opened.'**
/// **'Your local data could not be opened.'**
String get openFailed;
/// No description provided for @saveFailed.
@@ -233,12 +239,30 @@ abstract class AppLocalizations {
/// **'Session complete'**
String get timerCompleted;
/// No description provided for @focusCurrentIntention.
///
/// In en, this message translates to:
/// **'Today\'s focus: {intention}'**
String focusCurrentIntention(String intention);
/// No description provided for @focusNoIntentionTitle.
///
/// In en, this message translates to:
/// **'Start this session first'**
String get focusNoIntentionTitle;
/// No description provided for @reflectOneMinute.
///
/// In en, this message translates to:
/// **'Reflect for 1 min'**
/// **'Write progress note'**
String get reflectOneMinute;
/// No description provided for @startFocus.
///
/// In en, this message translates to:
/// **'Start focus'**
String get startFocus;
/// No description provided for @startAnotherSession.
///
/// In en, this message translates to:
@@ -263,6 +287,18 @@ abstract class AppLocalizations {
/// **'End'**
String get end;
/// No description provided for @completeSession.
///
/// In en, this message translates to:
/// **'Complete session'**
String get completeSession;
/// No description provided for @continueSession.
///
/// In en, this message translates to:
/// **'Continue session'**
String get continueSession;
/// No description provided for @resume.
///
/// In en, this message translates to:
@@ -314,7 +350,7 @@ abstract class AppLocalizations {
/// No description provided for @emptyHabitPrompt.
///
/// In en, this message translates to:
/// **'Add one repeatable habit.'**
/// **'Keep this optional and small.'**
String get emptyHabitPrompt;
/// No description provided for @viewAllHabits.
@@ -392,7 +428,7 @@ abstract class AppLocalizations {
/// No description provided for @dailyStepsComplete.
///
/// In en, this message translates to:
/// **'{completed}/4 daily steps complete'**
/// **'{completed}/3 daily steps complete'**
String dailyStepsComplete(int completed);
/// No description provided for @habitOptions.
@@ -416,19 +452,19 @@ abstract class AppLocalizations {
/// No description provided for @habitsTitle.
///
/// In en, this message translates to:
/// **'Habits'**
/// **'Supporting habits'**
String get habitsTitle;
/// No description provided for @habitsSubtitle.
///
/// In en, this message translates to:
/// **'Keep it small and repeatable.'**
/// **'Optional repeatable steps. Today\'s focus still comes first.'**
String get habitsSubtitle;
/// No description provided for @emptyHabitsTitle.
///
/// In en, this message translates to:
/// **'Add a small habit'**
/// **'Add a supporting habit'**
String get emptyHabitsTitle;
/// No description provided for @addHabitTitle.
@@ -461,16 +497,22 @@ abstract class AppLocalizations {
/// **'Save'**
String get save;
/// No description provided for @textEntryRequired.
///
/// In en, this message translates to:
/// **'Write a short line first.'**
String get textEntryRequired;
/// No description provided for @setIntentionTitle.
///
/// In en, this message translates to:
/// **'Set intention'**
/// **'Set today\'s focus'**
String get setIntentionTitle;
/// No description provided for @editIntentionTitle.
///
/// In en, this message translates to:
/// **'Edit intention'**
/// **'Edit today\'s focus'**
String get editIntentionTitle;
/// No description provided for @intentionHint.
@@ -554,7 +596,7 @@ abstract class AppLocalizations {
/// No description provided for @privacyBodyTwo.
///
/// In en, this message translates to:
/// **'You can clear today\'s progress or delete all local data from Me at any time.'**
/// **'You can clear today\'s progress or delete all local data from settings at any time.'**
String get privacyBodyTwo;
/// No description provided for @reflectionEmptyError.
@@ -572,21 +614,27 @@ abstract class AppLocalizations {
/// No description provided for @reflectionSheetTitle.
///
/// In en, this message translates to:
/// **'How did it go?'**
/// **'Leave one progress note'**
String get reflectionSheetTitle;
/// No description provided for @reflectionSheetSubtitle.
///
/// In en, this message translates to:
/// **'Keep what worked. Adjust one thing.'**
/// **'Save what moved forward so next time has a clear start.'**
String get reflectionSheetSubtitle;
/// No description provided for @reflectionHint.
///
/// In en, this message translates to:
/// **'One useful sentence is enough.'**
/// **'What moved forward?'**
String get reflectionHint;
/// No description provided for @reflectionMoodLabel.
///
/// In en, this message translates to:
/// **'Mood is optional'**
String get reflectionMoodLabel;
/// No description provided for @savingLabel.
///
/// In en, this message translates to:
@@ -596,7 +644,7 @@ abstract class AppLocalizations {
/// No description provided for @saveReflection.
///
/// In en, this message translates to:
/// **'Save reflection'**
/// **'Save note'**
String get saveReflection;
/// No description provided for @moodCalm.
@@ -626,15 +674,21 @@ abstract class AppLocalizations {
/// No description provided for @meTitle.
///
/// In en, this message translates to:
/// **'Your garden'**
/// **'Me'**
String get meTitle;
/// No description provided for @meSubtitle.
///
/// In en, this message translates to:
/// **'Preferences and records stay on this device.'**
/// **'Manage your rhythm, appearance, and records.'**
String get meSubtitle;
/// No description provided for @appSettingsTitle.
///
/// In en, this message translates to:
/// **'App settings'**
String get appSettingsTitle;
/// No description provided for @appearance.
///
/// In en, this message translates to:
@@ -698,9 +752,21 @@ abstract class AppLocalizations {
/// No description provided for @preferencesDetail.
///
/// In en, this message translates to:
/// **'Make it feel right'**
/// **'Theme, language, and default focus'**
String get preferencesDetail;
/// No description provided for @defaultFocusDuration.
///
/// In en, this message translates to:
/// **'Default focus'**
String get defaultFocusDuration;
/// No description provided for @defaultFocusDurationDetail.
///
/// In en, this message translates to:
/// **'Next session'**
String get defaultFocusDurationDetail;
/// No description provided for @localData.
///
/// In en, this message translates to:
@@ -716,13 +782,13 @@ abstract class AppLocalizations {
/// No description provided for @personalGardenTitle.
///
/// In en, this message translates to:
/// **'Your focus garden'**
/// **'My accumulation'**
String get personalGardenTitle;
/// No description provided for @personalGardenSubtitle.
///
/// In en, this message translates to:
/// **'Every small session leaves a trace you can return to.'**
/// **'Your local action records, progress notes, and app controls stay on this device.'**
String get personalGardenSubtitle;
/// No description provided for @focusDataLabel.
@@ -740,9 +806,27 @@ abstract class AppLocalizations {
/// No description provided for @notesDataLabel.
///
/// In en, this message translates to:
/// **'Notes'**
/// **'Progress'**
String get notesDataLabel;
/// No description provided for @totalActionTimeLabel.
///
/// In en, this message translates to:
/// **'Action time'**
String get totalActionTimeLabel;
/// No description provided for @recordsCountLabel.
///
/// In en, this message translates to:
/// **'Records'**
String get recordsCountLabel;
/// No description provided for @progressNotesCountLabel.
///
/// In en, this message translates to:
/// **'Progress notes'**
String get progressNotesCountLabel;
/// No description provided for @clearToday.
///
/// In en, this message translates to:
@@ -785,72 +869,60 @@ abstract class AppLocalizations {
/// **'Local data deleted'**
String get localDataCleared;
/// No description provided for @journeyTitle.
///
/// In en, this message translates to:
/// **'Progress'**
String get journeyTitle;
/// No description provided for @journeyOverviewLabel.
/// No description provided for @todayLoopLabel.
///
/// In en, this message translates to:
/// **'TODAY\'S LOOP'**
String get journeyOverviewLabel;
String get todayLoopLabel;
/// No description provided for @journeyOverviewStartTitle.
/// No description provided for @todayLoopStartTitle.
///
/// In en, this message translates to:
/// **'Start with one clear focus'**
String get journeyOverviewStartTitle;
String get todayLoopStartTitle;
/// No description provided for @journeyOverviewStartBody.
/// No description provided for @todayLoopStartBody.
///
/// In en, this message translates to:
/// **'Pick the thing that would make today feel finished.'**
String get journeyOverviewStartBody;
/// **'Pick the one result that matters today.'**
String get todayLoopStartBody;
/// No description provided for @journeyOverviewActiveTitle.
/// No description provided for @todayLoopActiveTitle.
///
/// In en, this message translates to:
/// **'Your day is taking shape'**
String get journeyOverviewActiveTitle;
String get todayLoopActiveTitle;
/// No description provided for @journeyOverviewActiveBody.
/// No description provided for @todayLoopActiveBody.
///
/// In en, this message translates to:
/// **'Keep the loop moving: focus, habits, then a short reflection.'**
String get journeyOverviewActiveBody;
/// **'Focus once, then leave one useful note.'**
String get todayLoopActiveBody;
/// No description provided for @journeyOverviewDoneTitle.
/// No description provided for @todayLoopDoneTitle.
///
/// In en, this message translates to:
/// **'Loop complete'**
String get journeyOverviewDoneTitle;
/// **'Progress saved'**
String get todayLoopDoneTitle;
/// No description provided for @journeyOverviewDoneBody.
/// No description provided for @todayLoopDoneBody.
///
/// In en, this message translates to:
/// **'You left a clean record for future you.'**
String get journeyOverviewDoneBody;
/// **'You moved today forward and left a clear place to continue.'**
String get todayLoopDoneBody;
/// No description provided for @journeyOverviewProgress.
/// No description provided for @todayLoopProgress.
///
/// In en, this message translates to:
/// **'{completed}/4 done'**
String journeyOverviewProgress(int completed);
/// **'{completed}/3 done'**
String todayLoopProgress(int completed);
/// No description provided for @reviewNow.
///
/// In en, this message translates to:
/// **'Reflect now'**
/// **'Write a note'**
String get reviewNow;
/// No description provided for @journeyStatsTitle.
///
/// In en, this message translates to:
/// **'7-day rhythm'**
String get journeyStatsTitle;
/// No description provided for @weekFocusLabel.
///
/// In en, this message translates to:
@@ -926,7 +998,7 @@ abstract class AppLocalizations {
/// No description provided for @emptyFocusHistoryBody.
///
/// In en, this message translates to:
/// **'Start one session from Today and it will appear here.'**
/// **'Finish one focus session and it will appear here.'**
String get emptyFocusHistoryBody;
/// No description provided for @emptyNotesHistoryTitle.
@@ -938,9 +1010,81 @@ abstract class AppLocalizations {
/// No description provided for @emptyNotesHistoryBody.
///
/// In en, this message translates to:
/// **'Save a short reflection after focus to make your progress visible.'**
/// **'Save one reflection after a session and it will appear here.'**
String get emptyNotesHistoryBody;
/// No description provided for @recordsEmptyTitle.
///
/// In en, this message translates to:
/// **'No progress records yet'**
String get recordsEmptyTitle;
/// No description provided for @recordsEmptyBody.
///
/// In en, this message translates to:
/// **'Finish one focus session and leave a note to keep today\'s progress here.'**
String get recordsEmptyBody;
/// No description provided for @recordsCalendarTitle.
///
/// In en, this message translates to:
/// **'Activity dates'**
String get recordsCalendarTitle;
/// No description provided for @recordsDateLedgerTitle.
///
/// In en, this message translates to:
/// **'Date ledger'**
String get recordsDateLedgerTitle;
/// No description provided for @backToToday.
///
/// In en, this message translates to:
/// **'Back to Today'**
String get backToToday;
/// No description provided for @recordsNoIntention.
///
/// In en, this message translates to:
/// **'Unnamed daily focus'**
String get recordsNoIntention;
/// No description provided for @recordsNoReflection.
///
/// In en, this message translates to:
/// **'No progress note yet'**
String get recordsNoReflection;
/// No description provided for @recordsMissingProgressHelper.
///
/// In en, this message translates to:
/// **'Add one line so this is easier to pick up later.'**
String get recordsMissingProgressHelper;
/// No description provided for @addProgressNote.
///
/// In en, this message translates to:
/// **'Add progress note'**
String get addProgressNote;
/// No description provided for @continueThisAction.
///
/// In en, this message translates to:
/// **'Continue this'**
String get continueThisAction;
/// No description provided for @replaceTodayActionTitle.
///
/// In en, this message translates to:
/// **'Replace today\'s action?'**
String get replaceTodayActionTitle;
/// No description provided for @replaceTodayActionBody.
///
/// In en, this message translates to:
/// **'This will change today\'s action to \"{actionTitle}\". Existing records will stay.'**
String replaceTodayActionBody(String actionTitle);
/// No description provided for @recentFocus.
///
/// In en, this message translates to:
@@ -998,92 +1142,290 @@ abstract class AppLocalizations {
/// No description provided for @reflectionAction.
///
/// In en, this message translates to:
/// **'Close the loop with one sentence'**
/// **'Write one sentence to close today'**
String get reflectionAction;
/// No description provided for @growthTitle.
/// No description provided for @nextActionTitle.
///
/// In en, this message translates to:
/// **'Growth'**
String get growthTitle;
/// **'Next action'**
String get nextActionTitle;
/// No description provided for @growthFlowerOpen.
/// No description provided for @nextActionSetFocusTitle.
///
/// In en, this message translates to:
/// **'Today\'s flower is open'**
String get growthFlowerOpen;
/// **'Write today\'s focus'**
String get nextActionSetFocusTitle;
/// No description provided for @growthLocked.
/// No description provided for @nextActionSetFocusBody.
///
/// In en, this message translates to:
/// **'Unlocked after reflection'**
String get growthLocked;
/// **'One short line is enough. You can adjust it later.'**
String get nextActionSetFocusBody;
/// No description provided for @sevenDayGrowth.
/// No description provided for @nextActionStartFocusTitle.
///
/// In en, this message translates to:
/// **'7-day growth'**
String get sevenDayGrowth;
/// **'Start 10 minutes'**
String get nextActionStartFocusTitle;
/// No description provided for @sevenDayGrowthSubtitle.
/// No description provided for @nextActionStartFocusBody.
///
/// In en, this message translates to:
/// **'Small actions become visible over time.'**
String get sevenDayGrowthSubtitle;
/// **'Writing the action is optional. Start first.'**
String get nextActionStartFocusBody;
/// No description provided for @growthSeed.
/// No description provided for @nextActionStartActionBody.
///
/// In en, this message translates to:
/// **'seed'**
String get growthSeed;
/// **'Move this forward in one small session, then leave one progress note.'**
String get nextActionStartActionBody;
/// No description provided for @growthSprout.
/// No description provided for @nextActionHabitsTitle.
///
/// In en, this message translates to:
/// **'sprout'**
String get growthSprout;
/// **'Finish one small habit'**
String get nextActionHabitsTitle;
/// No description provided for @growthLeaves.
/// No description provided for @nextActionHabitsBody.
///
/// In en, this message translates to:
/// **'leaves'**
String get growthLeaves;
/// **'Complete one repeatable step while it is still easy.'**
String get nextActionHabitsBody;
/// No description provided for @growthFlower.
/// No description provided for @nextActionReflectTitle.
///
/// In en, this message translates to:
/// **'flower'**
String get growthFlower;
/// **'Write a quick note'**
String get nextActionReflectTitle;
/// No description provided for @growthDaySemantic.
/// No description provided for @nextActionReflectBody.
///
/// In en, this message translates to:
/// **'{date}, {stage}'**
String growthDaySemantic(Object date, Object stage);
/// **'Record what moved forward so the next start is easier.'**
String get nextActionReflectBody;
/// No description provided for @growthSeedSemantic.
/// No description provided for @nextActionCompleteTitle.
///
/// In en, this message translates to:
/// **'A seed waiting for today\'s intention'**
String get growthSeedSemantic;
/// **'Today moved forward'**
String get nextActionCompleteTitle;
/// No description provided for @growthSproutSemantic.
/// No description provided for @nextActionCompleteBody.
///
/// In en, this message translates to:
/// **'A sprout growing from today\'s intention'**
String get growthSproutSemantic;
/// **'Your note is saved. Continue only if it still feels useful.'**
String get nextActionCompleteBody;
/// No description provided for @growthLeavesSemantic.
/// No description provided for @nextActionContinueFocus.
///
/// In en, this message translates to:
/// **'A young plant growing with today\'s progress'**
String get growthLeavesSemantic;
/// **'Focus {minutes} min again'**
String nextActionContinueFocus(int minutes);
/// No description provided for @growthFlowerSemantic.
/// No description provided for @habitTemplatesTitle.
///
/// In en, this message translates to:
/// **'A flower marking a complete daily cycle'**
String get growthFlowerSemantic;
/// **'Useful starts'**
String get habitTemplatesTitle;
/// No description provided for @habitTemplatesBody.
///
/// In en, this message translates to:
/// **'Add one tiny repeatable action.'**
String get habitTemplatesBody;
/// No description provided for @habitTemplateStretch.
///
/// In en, this message translates to:
/// **'2 min stretch'**
String get habitTemplateStretch;
/// No description provided for @habitTemplateRead.
///
/// In en, this message translates to:
/// **'Read 10 pages'**
String get habitTemplateRead;
/// No description provided for @habitTemplateWalk.
///
/// In en, this message translates to:
/// **'Walk outside'**
String get habitTemplateWalk;
/// No description provided for @habitTemplateNoScroll.
///
/// In en, this message translates to:
/// **'No-scroll lunch'**
String get habitTemplateNoScroll;
/// No description provided for @languageDetail.
///
/// In en, this message translates to:
/// **'Interface language'**
String get languageDetail;
/// No description provided for @recordsAndPrivacyTitle.
///
/// In en, this message translates to:
/// **'Records & privacy'**
String get recordsAndPrivacyTitle;
/// No description provided for @recordsAndPrivacyBody.
///
/// In en, this message translates to:
/// **'Your history stays local. Keep, reset, or delete it when needed.'**
String get recordsAndPrivacyBody;
/// No description provided for @welcomeTrustLabel.
///
/// In en, this message translates to:
/// **'TODAY'**
String get welcomeTrustLabel;
/// No description provided for @welcomeTitle.
///
/// In en, this message translates to:
/// **'Plant just one thing each day.'**
String get welcomeTitle;
/// No description provided for @welcomeBody.
///
/// In en, this message translates to:
/// **'Start with today\'s most important thing.'**
String get welcomeBody;
/// No description provided for @welcomeNext.
///
/// In en, this message translates to:
/// **'The next step starts here.'**
String get welcomeNext;
/// No description provided for @onboardingFocusBadge.
///
/// In en, this message translates to:
/// **'FOCUS'**
String get onboardingFocusBadge;
/// No description provided for @onboardingFocusTitle.
///
/// In en, this message translates to:
/// **'Protect 10 minutes first.'**
String get onboardingFocusTitle;
/// No description provided for @onboardingFocusBody.
///
/// In en, this message translates to:
/// **'It does not have to be long. Starting creates progress.'**
String get onboardingFocusBody;
/// No description provided for @onboardingFocusNext.
///
/// In en, this message translates to:
/// **'Keep this one thing in front of you while you focus.'**
String get onboardingFocusNext;
/// No description provided for @onboardingReflectBadge.
///
/// In en, this message translates to:
/// **'NOTE'**
String get onboardingReflectBadge;
/// No description provided for @onboardingReflectTitle.
///
/// In en, this message translates to:
/// **'Leave one progress note.'**
String get onboardingReflectTitle;
/// No description provided for @onboardingReflectBody.
///
/// In en, this message translates to:
/// **'When you finish, write one line so you know where to continue next time.'**
String get onboardingReflectBody;
/// No description provided for @onboardingReflectNext.
///
/// In en, this message translates to:
/// **'Now set today\'s focus.'**
String get onboardingReflectNext;
/// No description provided for @onboardingNext.
///
/// In en, this message translates to:
/// **'Continue'**
String get onboardingNext;
/// No description provided for @welcomeStart.
///
/// In en, this message translates to:
/// **'Set today\'s focus'**
String get welcomeStart;
/// No description provided for @welcomeSkip.
///
/// In en, this message translates to:
/// **'Skip'**
String get welcomeSkip;
/// No description provided for @landingTitle.
///
/// In en, this message translates to:
/// **'Start with 10 minutes.'**
String get landingTitle;
/// No description provided for @landingBody.
///
/// In en, this message translates to:
/// **'Writing is optional. Start first.'**
String get landingBody;
/// No description provided for @landingFocusLabel.
///
/// In en, this message translates to:
/// **'What do you want to do first?'**
String get landingFocusLabel;
/// No description provided for @landingFocusEntry.
///
/// In en, this message translates to:
/// **'Write down what you will do'**
String get landingFocusEntry;
/// No description provided for @landingFocusHint.
///
/// In en, this message translates to:
/// **'Example: finish the launch checklist'**
String get landingFocusHint;
/// No description provided for @customFocusDuration.
///
/// In en, this message translates to:
/// **'Custom length'**
String get customFocusDuration;
/// No description provided for @customFocusDurationHint.
///
/// In en, this message translates to:
/// **'Enter 1-180 minutes'**
String get customFocusDurationHint;
/// No description provided for @minutesInputLabel.
///
/// In en, this message translates to:
/// **'Minutes'**
String get minutesInputLabel;
/// No description provided for @landingStart.
///
/// In en, this message translates to:
/// **'Start 10 minutes'**
String get landingStart;
/// No description provided for @landingSkip.
///
/// In en, this message translates to:
/// **'Enter Today'**
String get landingSkip;
/// No description provided for @defaultHabitPlanTask.
///
@@ -1094,7 +1436,7 @@ abstract class AppLocalizations {
/// No description provided for @defaultHabitFocus.
///
/// In en, this message translates to:
/// **'Focus for 25 minutes'**
/// **'Focus for 10 minutes'**
String get defaultHabitFocus;
/// No description provided for @defaultHabitReflect.
+231 -56
View File
@@ -24,7 +24,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get navToday => 'Today';
@override
String get navJourney => 'Journey';
String get navRecords => 'Records';
@override
String get navMe => 'Me';
@@ -36,7 +36,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get couldNotOpenApp => 'Could not open Focus Seed.';
@override
String get openFailed => 'Your local garden could not be opened.';
String get loadingLocalData => 'Loading local data';
@override
String get openFailed => 'Your local data could not be opened.';
@override
String get saveFailed => 'That change could not be saved. Please try again.';
@@ -80,7 +83,18 @@ class AppLocalizationsEn extends AppLocalizations {
String get timerCompleted => 'Session complete';
@override
String get reflectOneMinute => 'Reflect for 1 min';
String focusCurrentIntention(String intention) {
return 'Today\'s focus: $intention';
}
@override
String get focusNoIntentionTitle => 'Start this session first';
@override
String get reflectOneMinute => 'Write progress note';
@override
String get startFocus => 'Start focus';
@override
String get startAnotherSession => 'Start another session';
@@ -94,6 +108,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get end => 'End';
@override
String get completeSession => 'Complete session';
@override
String get continueSession => 'Continue session';
@override
String get resume => 'Resume';
@@ -123,7 +143,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get add => 'Add';
@override
String get emptyHabitPrompt => 'Add one repeatable habit.';
String get emptyHabitPrompt => 'Keep this optional and small.';
@override
String viewAllHabits(int count) {
@@ -167,7 +187,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String dailyStepsComplete(int completed) {
return '$completed/4 daily steps complete';
return '$completed/3 daily steps complete';
}
@override
@@ -180,13 +200,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get delete => 'Delete';
@override
String get habitsTitle => 'Habits';
String get habitsTitle => 'Supporting habits';
@override
String get habitsSubtitle => 'Keep it small and repeatable.';
String get habitsSubtitle => 'Optional repeatable steps. Today\'s focus still comes first.';
@override
String get emptyHabitsTitle => 'Add a small habit';
String get emptyHabitsTitle => 'Add a supporting habit';
@override
String get addHabitTitle => 'Add habit';
@@ -204,10 +224,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get save => 'Save';
@override
String get setIntentionTitle => 'Set intention';
String get textEntryRequired => 'Write a short line first.';
@override
String get editIntentionTitle => 'Edit intention';
String get setIntentionTitle => 'Set today\'s focus';
@override
String get editIntentionTitle => 'Edit today\'s focus';
@override
String get intentionHint => 'What matters most today?';
@@ -249,7 +272,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get privacyBodyOne => 'Focus Seed stores your intention, habits, focus sessions, reflections, and settings locally on this device. This version has no sign-in, cloud sync, ads, analytics SDK, or billing SDK.';
@override
String get privacyBodyTwo => 'You can clear today\'s progress or delete all local data from Me at any time.';
String get privacyBodyTwo => 'You can clear today\'s progress or delete all local data from settings at any time.';
@override
String get reflectionEmptyError => 'Write one honest sentence.';
@@ -258,19 +281,22 @@ class AppLocalizationsEn extends AppLocalizations {
String get reflectionSaveError => 'Could not save. Please try again.';
@override
String get reflectionSheetTitle => 'How did it go?';
String get reflectionSheetTitle => 'Leave one progress note';
@override
String get reflectionSheetSubtitle => 'Keep what worked. Adjust one thing.';
String get reflectionSheetSubtitle => 'Save what moved forward so next time has a clear start.';
@override
String get reflectionHint => 'One useful sentence is enough.';
String get reflectionHint => 'What moved forward?';
@override
String get reflectionMoodLabel => 'Mood is optional';
@override
String get savingLabel => 'Saving';
@override
String get saveReflection => 'Save reflection';
String get saveReflection => 'Save note';
@override
String get moodCalm => 'Calm';
@@ -285,10 +311,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get moodTired => 'Tired';
@override
String get meTitle => 'Your garden';
String get meTitle => 'Me';
@override
String get meSubtitle => 'Preferences and records stay on this device.';
String get meSubtitle => 'Manage your rhythm, appearance, and records.';
@override
String get appSettingsTitle => 'App settings';
@override
String get appearance => 'Theme';
@@ -321,7 +350,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get preferencesTitle => 'Preferences';
@override
String get preferencesDetail => 'Make it feel right';
String get preferencesDetail => 'Theme, language, and default focus';
@override
String get defaultFocusDuration => 'Default focus';
@override
String get defaultFocusDurationDetail => 'Next session';
@override
String get localData => 'Local records';
@@ -330,10 +365,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get localDataDetail => 'Private on device';
@override
String get personalGardenTitle => 'Your focus garden';
String get personalGardenTitle => 'My accumulation';
@override
String get personalGardenSubtitle => 'Every small session leaves a trace you can return to.';
String get personalGardenSubtitle => 'Your local action records, progress notes, and app controls stay on this device.';
@override
String get focusDataLabel => 'Focus';
@@ -342,7 +377,16 @@ class AppLocalizationsEn extends AppLocalizations {
String get habitsDataLabel => 'Habits';
@override
String get notesDataLabel => 'Notes';
String get notesDataLabel => 'Progress';
@override
String get totalActionTimeLabel => 'Action time';
@override
String get recordsCountLabel => 'Records';
@override
String get progressNotesCountLabel => 'Progress notes';
@override
String get clearToday => 'Clear today';
@@ -366,39 +410,33 @@ class AppLocalizationsEn extends AppLocalizations {
String get localDataCleared => 'Local data deleted';
@override
String get journeyTitle => 'Progress';
String get todayLoopLabel => 'TODAY\'S LOOP';
@override
String get journeyOverviewLabel => 'TODAY\'S LOOP';
String get todayLoopStartTitle => 'Start with one clear focus';
@override
String get journeyOverviewStartTitle => 'Start with one clear focus';
String get todayLoopStartBody => 'Pick the one result that matters today.';
@override
String get journeyOverviewStartBody => 'Pick the thing that would make today feel finished.';
String get todayLoopActiveTitle => 'Your day is taking shape';
@override
String get journeyOverviewActiveTitle => 'Your day is taking shape';
String get todayLoopActiveBody => 'Focus once, then leave one useful note.';
@override
String get journeyOverviewActiveBody => 'Keep the loop moving: focus, habits, then a short reflection.';
String get todayLoopDoneTitle => 'Progress saved';
@override
String get journeyOverviewDoneTitle => 'Loop complete';
String get todayLoopDoneBody => 'You moved today forward and left a clear place to continue.';
@override
String get journeyOverviewDoneBody => 'You left a clean record for future you.';
@override
String journeyOverviewProgress(int completed) {
return '$completed/4 done';
String todayLoopProgress(int completed) {
return '$completed/3 done';
}
@override
String get reviewNow => 'Reflect now';
@override
String get journeyStatsTitle => '7-day rhythm';
String get reviewNow => 'Write a note';
@override
String get weekFocusLabel => 'Focus';
@@ -441,13 +479,51 @@ class AppLocalizationsEn extends AppLocalizations {
String get emptyFocusHistoryTitle => 'No focus sessions yet';
@override
String get emptyFocusHistoryBody => 'Start one session from Today and it will appear here.';
String get emptyFocusHistoryBody => 'Finish one focus session and it will appear here.';
@override
String get emptyNotesHistoryTitle => 'No notes yet';
@override
String get emptyNotesHistoryBody => 'Save a short reflection after focus to make your progress visible.';
String get emptyNotesHistoryBody => 'Save one reflection after a session and it will appear here.';
@override
String get recordsEmptyTitle => 'No progress records yet';
@override
String get recordsEmptyBody => 'Finish one focus session and leave a note to keep today\'s progress here.';
@override
String get recordsCalendarTitle => 'Activity dates';
@override
String get recordsDateLedgerTitle => 'Date ledger';
@override
String get backToToday => 'Back to Today';
@override
String get recordsNoIntention => 'Unnamed daily focus';
@override
String get recordsNoReflection => 'No progress note yet';
@override
String get recordsMissingProgressHelper => 'Add one line so this is easier to pick up later.';
@override
String get addProgressNote => 'Add progress note';
@override
String get continueThisAction => 'Continue this';
@override
String get replaceTodayActionTitle => 'Replace today\'s action?';
@override
String replaceTodayActionBody(String actionTitle) {
return 'This will change today\'s action to \"$actionTitle\". Existing records will stay.';
}
@override
String get recentFocus => 'Recent focus';
@@ -485,57 +561,156 @@ class AppLocalizationsEn extends AppLocalizations {
String get reflectionCaptured => 'Today\'s lesson is captured';
@override
String get reflectionAction => 'Close the loop with one sentence';
String get reflectionAction => 'Write one sentence to close today';
@override
String get growthTitle => 'Growth';
String get nextActionTitle => 'Next action';
@override
String get growthFlowerOpen => 'Today\'s flower is open';
String get nextActionSetFocusTitle => 'Write today\'s focus';
@override
String get growthLocked => 'Unlocked after reflection';
String get nextActionSetFocusBody => 'One short line is enough. You can adjust it later.';
@override
String get sevenDayGrowth => '7-day growth';
String get nextActionStartFocusTitle => 'Start 10 minutes';
@override
String get sevenDayGrowthSubtitle => 'Small actions become visible over time.';
String get nextActionStartFocusBody => 'Writing the action is optional. Start first.';
@override
String get growthSeed => 'seed';
String get nextActionStartActionBody => 'Move this forward in one small session, then leave one progress note.';
@override
String get growthSprout => 'sprout';
String get nextActionHabitsTitle => 'Finish one small habit';
@override
String get growthLeaves => 'leaves';
String get nextActionHabitsBody => 'Complete one repeatable step while it is still easy.';
@override
String get growthFlower => 'flower';
String get nextActionReflectTitle => 'Write a quick note';
@override
String growthDaySemantic(Object date, Object stage) {
return '$date, $stage';
String get nextActionReflectBody => 'Record what moved forward so the next start is easier.';
@override
String get nextActionCompleteTitle => 'Today moved forward';
@override
String get nextActionCompleteBody => 'Your note is saved. Continue only if it still feels useful.';
@override
String nextActionContinueFocus(int minutes) {
return 'Focus $minutes min again';
}
@override
String get growthSeedSemantic => 'A seed waiting for today\'s intention';
String get habitTemplatesTitle => 'Useful starts';
@override
String get growthSproutSemantic => 'A sprout growing from today\'s intention';
String get habitTemplatesBody => 'Add one tiny repeatable action.';
@override
String get growthLeavesSemantic => 'A young plant growing with today\'s progress';
String get habitTemplateStretch => '2 min stretch';
@override
String get growthFlowerSemantic => 'A flower marking a complete daily cycle';
String get habitTemplateRead => 'Read 10 pages';
@override
String get habitTemplateWalk => 'Walk outside';
@override
String get habitTemplateNoScroll => 'No-scroll lunch';
@override
String get languageDetail => 'Interface language';
@override
String get recordsAndPrivacyTitle => 'Records & privacy';
@override
String get recordsAndPrivacyBody => 'Your history stays local. Keep, reset, or delete it when needed.';
@override
String get welcomeTrustLabel => 'TODAY';
@override
String get welcomeTitle => 'Plant just one thing each day.';
@override
String get welcomeBody => 'Start with today\'s most important thing.';
@override
String get welcomeNext => 'The next step starts here.';
@override
String get onboardingFocusBadge => 'FOCUS';
@override
String get onboardingFocusTitle => 'Protect 10 minutes first.';
@override
String get onboardingFocusBody => 'It does not have to be long. Starting creates progress.';
@override
String get onboardingFocusNext => 'Keep this one thing in front of you while you focus.';
@override
String get onboardingReflectBadge => 'NOTE';
@override
String get onboardingReflectTitle => 'Leave one progress note.';
@override
String get onboardingReflectBody => 'When you finish, write one line so you know where to continue next time.';
@override
String get onboardingReflectNext => 'Now set today\'s focus.';
@override
String get onboardingNext => 'Continue';
@override
String get welcomeStart => 'Set today\'s focus';
@override
String get welcomeSkip => 'Skip';
@override
String get landingTitle => 'Start with 10 minutes.';
@override
String get landingBody => 'Writing is optional. Start first.';
@override
String get landingFocusLabel => 'What do you want to do first?';
@override
String get landingFocusEntry => 'Write down what you will do';
@override
String get landingFocusHint => 'Example: finish the launch checklist';
@override
String get customFocusDuration => 'Custom length';
@override
String get customFocusDurationHint => 'Enter 1-180 minutes';
@override
String get minutesInputLabel => 'Minutes';
@override
String get landingStart => 'Start 10 minutes';
@override
String get landingSkip => 'Enter Today';
@override
String get defaultHabitPlanTask => 'Plan one clear task';
@override
String get defaultHabitFocus => 'Focus for 25 minutes';
String get defaultHabitFocus => 'Focus for 10 minutes';
@override
String get defaultHabitReflect => 'Write one-line reflection';
+230 -55
View File
@@ -24,7 +24,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get navToday => '今日';
@override
String get navJourney => '进展';
String get navRecords => '记录';
@override
String get navMe => '我的';
@@ -35,6 +35,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get couldNotOpenApp => '无法打开 Focus Seed。';
@override
String get loadingLocalData => '正在读取本地数据';
@override
String get openFailed => '本地数据打开失败。';
@@ -80,7 +83,18 @@ class AppLocalizationsZh extends AppLocalizations {
String get timerCompleted => '已完成';
@override
String get reflectOneMinute => '复盘 1 分钟';
String focusCurrentIntention(String intention) {
return '今日重点:$intention';
}
@override
String get focusNoIntentionTitle => '先开始这一段';
@override
String get reflectOneMinute => '写一句进展';
@override
String get startFocus => '开始专注';
@override
String get startAnotherSession => '再来一轮';
@@ -94,6 +108,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get end => '结束';
@override
String get completeSession => '完成本轮';
@override
String get continueSession => '继续本轮';
@override
String get resume => '继续';
@@ -123,7 +143,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get add => '添加';
@override
String get emptyHabitPrompt => '添加一个可重复的小习惯。';
String get emptyHabitPrompt => '保持可选,也保持很小。';
@override
String viewAllHabits(int count) {
@@ -167,7 +187,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String dailyStepsComplete(int completed) {
return '今日完成 $completed/4';
return '今日完成 $completed/3';
}
@override
@@ -180,13 +200,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get delete => '删除';
@override
String get habitsTitle => '习惯';
String get habitsTitle => '辅助习惯';
@override
String get habitsSubtitle => '少量、可重复。';
String get habitsSubtitle => '可选的重复动作,今日重点仍然放在前面。';
@override
String get emptyHabitsTitle => '添加小习惯';
String get emptyHabitsTitle => '添加一个辅助习惯';
@override
String get addHabitTitle => '添加习惯';
@@ -204,10 +224,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get save => '保存';
@override
String get setIntentionTitle => '设置目标';
String get textEntryRequired => '先写一个短句。';
@override
String get editIntentionTitle => '编辑目标';
String get setIntentionTitle => '设置今日重点';
@override
String get editIntentionTitle => '编辑今日重点';
@override
String get intentionHint => '今天最重要的是什么?';
@@ -249,7 +272,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get privacyBodyOne => 'Focus Seed 只在本机保存目标、习惯、专注记录、复盘和设置。当前版本没有登录、云同步、广告、分析 SDK 或付费 SDK。';
@override
String get privacyBodyTwo => '你可以随时清理今日进度,或删除本机的全部本地数据。';
String get privacyBodyTwo => '你可以随时在设置中清理今日进度,或删除本机的全部本地数据。';
@override
String get reflectionEmptyError => '写一句真实的总结。';
@@ -258,19 +281,22 @@ class AppLocalizationsZh extends AppLocalizations {
String get reflectionSaveError => '保存失败,请重试。';
@override
String get reflectionSheetTitle => '复盘一下';
String get reflectionSheetTitle => '留下一句进展';
@override
String get reflectionSheetSubtitle => '保留有效的,调整一个点。';
String get reflectionSheetSubtitle => '记下刚推进了什么,下次就知道从哪里继续。';
@override
String get reflectionHint => '一句有用的话就够。';
String get reflectionHint => '刚才推进了什么?';
@override
String get reflectionMoodLabel => '状态可以稍后补';
@override
String get savingLabel => '保存中';
@override
String get saveReflection => '保存复盘';
String get saveReflection => '保存记录';
@override
String get moodCalm => '平静';
@@ -285,10 +311,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get moodTired => '疲惫';
@override
String get meTitle => '我的花园';
String get meTitle => '我的';
@override
String get meSubtitle => '偏好和记录都只保存在本机。';
String get meSubtitle => '管理你的节奏、外观和记录。';
@override
String get appSettingsTitle => '应用设置';
@override
String get appearance => '主题';
@@ -321,7 +350,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get preferencesTitle => '偏好';
@override
String get preferencesDetail => '让界面更顺手';
String get preferencesDetail => '主题、语言和默认专注';
@override
String get defaultFocusDuration => '默认专注';
@override
String get defaultFocusDurationDetail => '下一轮';
@override
String get localData => '本地记录';
@@ -330,10 +365,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get localDataDetail => '只在本机';
@override
String get personalGardenTitle => '你的专注花园';
String get personalGardenTitle => '我的累计';
@override
String get personalGardenSubtitle => '每一次小专注,都会留下可回看的痕迹。';
String get personalGardenSubtitle => '你的本地行动记录、进展和应用控制都保留在这台设备上。';
@override
String get focusDataLabel => '专注';
@@ -342,7 +377,16 @@ class AppLocalizationsZh extends AppLocalizations {
String get habitsDataLabel => '习惯';
@override
String get notesDataLabel => '记录';
String get notesDataLabel => '进展';
@override
String get totalActionTimeLabel => '行动时间';
@override
String get recordsCountLabel => '记录';
@override
String get progressNotesCountLabel => '进展';
@override
String get clearToday => '清理今天';
@@ -366,39 +410,33 @@ class AppLocalizationsZh extends AppLocalizations {
String get localDataCleared => '本地数据已删除';
@override
String get journeyTitle => '进展';
String get todayLoopLabel => '今日闭环';
@override
String get journeyOverviewLabel => '今日闭环';
String get todayLoopStartTitle => '先定下一个重点';
@override
String get journeyOverviewStartTitle => '先定下一个重点';
String get todayLoopStartBody => '先写下今天最重要的一件事。';
@override
String get journeyOverviewStartBody => '写下今天最值得完成的那件事。';
String get todayLoopActiveTitle => '今天正在成形';
@override
String get journeyOverviewActiveTitle => '今天正在成形';
String get todayLoopActiveBody => '先完成一轮专注,再留一句有用的记录。';
@override
String get journeyOverviewActiveBody => '继续推进:专注、打卡,然后用一句话复盘。';
String get todayLoopDoneTitle => '进展已保存';
@override
String get journeyOverviewDoneTitle => '今日闭环完成';
String get todayLoopDoneBody => '今天已经推进了一点,下次也知道从哪里继续。';
@override
String get journeyOverviewDoneBody => '你已经给未来的自己留下了一条清晰记录。';
@override
String journeyOverviewProgress(int completed) {
return '已完成 $completed/4';
String todayLoopProgress(int completed) {
return '已完成 $completed/3';
}
@override
String get reviewNow => '现在复盘';
@override
String get journeyStatsTitle => '7 日节奏';
String get reviewNow => '写一句记录';
@override
String get weekFocusLabel => '专注';
@@ -441,13 +479,51 @@ class AppLocalizationsZh extends AppLocalizations {
String get emptyFocusHistoryTitle => '还没有专注记录';
@override
String get emptyFocusHistoryBody => '从今日页开始一轮专注,完成后会出现在这里。';
String get emptyFocusHistoryBody => '完成一轮专注后,记录会出现在这里。';
@override
String get emptyNotesHistoryTitle => '还没有复盘';
@override
String get emptyNotesHistoryBody => '专注后保存一句复盘,让进展更清晰。';
String get emptyNotesHistoryBody => '专注后留一句复盘,这里会保留它。';
@override
String get recordsEmptyTitle => '还没有进展记录';
@override
String get recordsEmptyBody => '完成一次专注,再留一句记录,这里就会保留今天的进展。';
@override
String get recordsCalendarTitle => '活动日期';
@override
String get recordsDateLedgerTitle => '当日账本';
@override
String get backToToday => '回到今日';
@override
String get recordsNoIntention => '未命名今日重点';
@override
String get recordsNoReflection => '还没有进展记录';
@override
String get recordsMissingProgressHelper => '补一句,之后更容易接上。';
@override
String get addProgressNote => '补一句进展';
@override
String get continueThisAction => '继续这件事';
@override
String get replaceTodayActionTitle => '替换今天的行动?';
@override
String replaceTodayActionBody(String actionTitle) {
return '会把今天的行动改为“$actionTitle”,已有记录不会删除。';
}
@override
String get recentFocus => '最近专注';
@@ -485,57 +561,156 @@ class AppLocalizationsZh extends AppLocalizations {
String get reflectionCaptured => '今日复盘已记录';
@override
String get reflectionAction => '用一句话收尾';
String get reflectionAction => '用一句话给今天收尾';
@override
String get growthTitle => '成长';
String get nextActionTitle => '下一步';
@override
String get growthFlowerOpen => '今日花朵已开放';
String get nextActionSetFocusTitle => '写下今天的重点';
@override
String get growthLocked => '复盘后解锁';
String get nextActionSetFocusBody => '一个短句就够了,之后还可以调整。';
@override
String get sevenDayGrowth => '7 日成长';
String get nextActionStartFocusTitle => '开始 10 分钟';
@override
String get sevenDayGrowthSubtitle => '连续行动会被看见。';
String get nextActionStartFocusBody => '写不写都可以,先开始。';
@override
String get growthSeed => '种子';
String get nextActionStartActionBody => '先推进一小段,完成后留一句进展。';
@override
String get growthSprout => '发芽';
String get nextActionHabitsTitle => '完成一个小习惯';
@override
String get growthLeaves => '生长';
String get nextActionHabitsBody => '趁着还顺手,先做掉一个可重复的小动作。';
@override
String get growthFlower => '开花';
String get nextActionReflectTitle => '写一句记录';
@override
String growthDaySemantic(Object date, Object stage) {
return '$date,$stage';
String get nextActionReflectBody => '记下刚才推进了什么,下次更容易继续。';
@override
String get nextActionCompleteTitle => '今天已经推进了一点';
@override
String get nextActionCompleteBody => '进展已经留下,想继续时再来一段。';
@override
String nextActionContinueFocus(int minutes) {
return '再专注 $minutes 分钟';
}
@override
String get growthSeedSemantic => '等待今日目标的种子';
String get habitTemplatesTitle => '可以直接开始';
@override
String get growthSproutSemantic => '目标已种下,正在发芽';
String get habitTemplatesBody => '添加一个很小但能重复的动作。';
@override
String get growthLeavesSemantic => '今日进度让植物生长';
String get habitTemplateStretch => '2 分钟拉伸';
@override
String get growthFlowerSemantic => '今日闭环完成,花朵开放';
String get habitTemplateRead => '读 10 页书';
@override
String get habitTemplateWalk => '出门走走';
@override
String get habitTemplateNoScroll => '午餐不刷手机';
@override
String get languageDetail => '界面语言';
@override
String get recordsAndPrivacyTitle => '记录与隐私';
@override
String get recordsAndPrivacyBody => '历史只保存在本机。你可以保留、重置或彻底删除。';
@override
String get welcomeTrustLabel => '今日';
@override
String get welcomeTitle => '每天只种一件事。';
@override
String get welcomeBody => '先选今天最重要的一件。';
@override
String get welcomeNext => '下一步会从这里开始。';
@override
String get onboardingFocusBadge => '专注';
@override
String get onboardingFocusTitle => '先守住 10 分钟。';
@override
String get onboardingFocusBody => '时间不用长,能开始就有进展。';
@override
String get onboardingFocusNext => '专注时,把这件事留在眼前。';
@override
String get onboardingReflectBadge => '记录';
@override
String get onboardingReflectTitle => '留下一句进展。';
@override
String get onboardingReflectBody => '结束时记一句,下次就知道从哪里继续。';
@override
String get onboardingReflectNext => '现在设置今天重点。';
@override
String get onboardingNext => '继续';
@override
String get welcomeStart => '设置今天重点';
@override
String get welcomeSkip => '跳过';
@override
String get landingTitle => '先开始 10 分钟。';
@override
String get landingBody => '写不写都可以,先开始。';
@override
String get landingFocusLabel => '今天想先做什么?';
@override
String get landingFocusEntry => '写下这次要做的事';
@override
String get landingFocusHint => '例如:完成上线检查清单';
@override
String get customFocusDuration => '自定义时长';
@override
String get customFocusDurationHint => '输入 1-180 分钟';
@override
String get minutesInputLabel => '分钟数';
@override
String get landingStart => '开始 10 分钟';
@override
String get landingSkip => '进入今日页';
@override
String get defaultHabitPlanTask => '计划一个明确任务';
@override
String get defaultHabitFocus => '专注 25 分钟';
String get defaultHabitFocus => '专注 10 分钟';
@override
String get defaultHabitReflect => '写一句复盘';
-19
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../core/ambience.dart';
import '../models/growth_stage.dart';
import '../models/mood.dart';
import 'generated/app_localizations.dart';
@@ -46,24 +45,6 @@ String localizedMood(AppLocalizations l10n, Mood mood) {
};
}
String localizedGrowthStage(AppLocalizations l10n, GrowthStage stage) {
return switch (stage) {
GrowthStage.seed => l10n.growthSeed,
GrowthStage.sprout => l10n.growthSprout,
GrowthStage.leaves => l10n.growthLeaves,
GrowthStage.flower => l10n.growthFlower,
};
}
String localizedGrowthSemantic(AppLocalizations l10n, GrowthStage stage) {
return switch (stage) {
GrowthStage.seed => l10n.growthSeedSemantic,
GrowthStage.sprout => l10n.growthSproutSemantic,
GrowthStage.leaves => l10n.growthLeavesSemantic,
GrowthStage.flower => l10n.growthFlowerSemantic,
};
}
List<String> localizedStarterHabits(AppLocalizations l10n) {
return [
l10n.defaultHabitPlanTask,
+126 -2
View File
@@ -1,5 +1,8 @@
import '../core/focus_durations.dart';
import 'daily_action.dart';
import 'focus_session.dart';
import 'habit.dart';
import 'progress_note.dart';
import 'reflection_entry.dart';
class AppState {
@@ -9,6 +12,11 @@ class AppState {
required this.habits,
required this.sessions,
required this.reflections,
this.actions = const [],
this.selectedActionId = '',
this.progressNotes = const [],
this.recentLabels = const [],
this.defaultFocusMinutes = defaultFocusDurationMinutes,
});
final String anchor;
@@ -16,23 +24,101 @@ class AppState {
final List<Habit> habits;
final List<FocusSession> sessions;
final List<ReflectionEntry> reflections;
final List<DailyAction> actions;
final String selectedActionId;
final List<ProgressNote> progressNotes;
final List<String> recentLabels;
final int defaultFocusMinutes;
bool get isFreshStart =>
habits.isEmpty &&
sessions.isEmpty &&
reflections.isEmpty &&
anchor.isEmpty;
anchor.isEmpty &&
actions.isEmpty &&
progressNotes.isEmpty;
String activeAnchor(String dateKey) {
return anchorDateKey == dateKey ? anchor : '';
}
List<DailyAction> actionsForDate(
String dateKey, {
bool includeArchived = false,
}) {
final filtered = actions
.where(
(action) =>
action.dateKey == dateKey &&
(includeArchived || !action.isArchived),
)
.toList(growable: false);
return [...filtered]..sort((a, b) {
final byOrder = a.orderIndex.compareTo(b.orderIndex);
if (byOrder != 0) {
return byOrder;
}
return a.createdAt.compareTo(b.createdAt);
});
}
DailyAction? actionById(String? id) {
if (id == null || id.isEmpty) {
return null;
}
for (final action in actions) {
if (action.id == id) {
return action;
}
}
return null;
}
DailyAction? selectedAction(String dateKey) {
final todayActions = actionsForDate(dateKey);
if (todayActions.isEmpty) {
return null;
}
final selected = actionById(selectedActionId);
if (selected != null &&
selected.dateKey == dateKey &&
!selected.isArchived) {
return selected;
}
for (final action in todayActions) {
if (action.status == DailyActionStatus.active ||
action.status == DailyActionStatus.focused) {
return action;
}
}
return todayActions.first;
}
List<FocusSession> sessionsForAction(String actionId) {
return sessions
.where((session) => session.actionId == actionId)
.toList(growable: false)
..sort((a, b) => b.completedAt.compareTo(a.completedAt));
}
List<ProgressNote> progressNotesForAction(String actionId) {
return progressNotes
.where((note) => note.actionId == actionId)
.toList(growable: false)
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
AppState copyWith({
String? anchor,
String? anchorDateKey,
List<Habit>? habits,
List<FocusSession>? sessions,
List<ReflectionEntry>? reflections,
List<DailyAction>? actions,
String? selectedActionId,
List<ProgressNote>? progressNotes,
List<String>? recentLabels,
int? defaultFocusMinutes,
}) {
return AppState(
anchor: anchor ?? this.anchor,
@@ -40,6 +126,11 @@ class AppState {
habits: habits ?? this.habits,
sessions: sessions ?? this.sessions,
reflections: reflections ?? this.reflections,
actions: actions ?? this.actions,
selectedActionId: selectedActionId ?? this.selectedActionId,
progressNotes: progressNotes ?? this.progressNotes,
recentLabels: recentLabels ?? this.recentLabels,
defaultFocusMinutes: defaultFocusMinutes ?? this.defaultFocusMinutes,
);
}
@@ -52,11 +143,19 @@ class AppState {
'sessions': sessions.map((session) => session.toJson()).toList(),
'reflections':
reflections.map((reflection) => reflection.toJson()).toList(),
'actions': actions.map((action) => action.toJson()).toList(),
'selectedActionId': selectedActionId,
'progressNotes': progressNotes.map((note) => note.toJson()).toList(),
'recentLabels': recentLabels,
'defaultFocusMinutes': defaultFocusMinutes,
};
}
static AppState fromJson(Map<String, dynamic> json) {
final anchor = (json['anchor'] as String?) ?? '';
final defaultMinutes = _validDefaultFocusMinutes(
json['defaultFocusMinutes'] as int?,
);
return AppState(
anchor: anchor,
anchorDateKey:
@@ -69,6 +168,14 @@ class AppState {
_jsonObjects(
json['reflections'],
).map(ReflectionEntry.fromJson).toList(),
actions: _jsonObjects(json['actions']).map(DailyAction.fromJson).toList(),
selectedActionId: (json['selectedActionId'] as String?) ?? '',
progressNotes:
_jsonObjects(
json['progressNotes'],
).map(ProgressNote.fromJson).toList(),
recentLabels: _stringList(json['recentLabels']),
defaultFocusMinutes: defaultMinutes,
);
}
@@ -79,6 +186,16 @@ class AppState {
return value.whereType<Map<String, dynamic>>();
}
static List<String> _stringList(Object? value) {
if (value is! List<Object?>) {
return const [];
}
return [
for (final item in value)
if (item is String && item.trim().isNotEmpty) item.trim(),
];
}
static AppState starter({List<String>? defaultHabitTitles}) {
final now = DateTime.now();
final titles =
@@ -86,7 +203,7 @@ class AppState {
? defaultHabitTitles!
: const [
'Plan one clear task',
'Focus for 25 minutes',
'Focus for 10 minutes',
'Write one-line reflection',
];
return AppState(
@@ -117,6 +234,13 @@ class AppState {
);
}
static int _validDefaultFocusMinutes(int? value) {
if (value == null || !isFocusDurationPreset(value)) {
return defaultFocusDurationMinutes;
}
return value;
}
static String _currentDateKey([DateTime? value]) {
final date = value ?? DateTime.now();
return '${date.year.toString().padLeft(4, '0')}-'
+26 -2
View File
@@ -3,25 +3,49 @@ class FocusSession {
required this.id,
required this.minutes,
required this.completedAt,
});
this.intention = '',
this.actionId,
String? actionTitleSnapshot,
int? actualSeconds,
}) : actualSeconds = actualSeconds ?? minutes * 60,
actionTitleSnapshot = actionTitleSnapshot ?? intention;
final String id;
final int minutes;
final int actualSeconds;
final DateTime completedAt;
final String intention;
final String? actionId;
final String actionTitleSnapshot;
int get actualMinutes => actualSeconds <= 0 ? 0 : (actualSeconds + 59) ~/ 60;
String get actionTitle =>
actionTitleSnapshot.trim().isEmpty
? intention.trim()
: actionTitleSnapshot.trim();
Map<String, dynamic> toJson() {
return {
'id': id,
'minutes': minutes,
'actualSeconds': actualSeconds,
'completedAt': completedAt.toIso8601String(),
'intention': intention,
'actionId': actionId,
'actionTitleSnapshot': actionTitleSnapshot,
};
}
static FocusSession fromJson(Map<String, dynamic> json) {
final minutes = json['minutes'] as int;
return FocusSession(
id: json['id'] as String,
minutes: json['minutes'] as int,
minutes: minutes,
actualSeconds: (json['actualSeconds'] as int?) ?? minutes * 60,
completedAt: DateTime.parse(json['completedAt'] as String),
intention: (json['intention'] as String?) ?? '',
actionId: json['actionId'] as String?,
actionTitleSnapshot: json['actionTitleSnapshot'] as String?,
);
}
}
-29
View File
@@ -1,29 +0,0 @@
import '../core/date_keys.dart';
import 'app_state.dart';
enum GrowthStage { seed, sprout, leaves, flower }
GrowthStage growthStageFor(AppState state, String dateKey) {
if (state.reflections.any((entry) => entry.dateKey == dateKey)) {
return GrowthStage.flower;
}
final hasFocus = minutesForDate(state.sessions, dateKey) > 0;
final hasHabit = state.habits.any(
(habit) => habit.completedDates.contains(dateKey),
);
if (hasFocus || hasHabit) {
return GrowthStage.leaves;
}
return state.activeAnchor(dateKey).isEmpty
? GrowthStage.seed
: GrowthStage.sprout;
}
extension GrowthStagePresentation on GrowthStage {
String get assetPath => switch (this) {
GrowthStage.seed => 'assets/images/growth/growth-seed.png',
GrowthStage.sprout => 'assets/images/growth/growth-sprout.png',
GrowthStage.leaves => 'assets/images/growth/growth-leaves.png',
GrowthStage.flower => 'assets/images/growth/growth-flower.png',
};
}
-50
View File
@@ -1,50 +0,0 @@
import '../core/date_keys.dart';
import 'app_state.dart';
class JourneyStats {
const JourneyStats({
required this.currentStreakDays,
required this.weekFocusMinutes,
required this.activeDaysThisWeek,
required this.habitCompletionRate,
});
final int currentStreakDays;
final int weekFocusMinutes;
final int activeDaysThisWeek;
final double habitCompletionRate;
static JourneyStats fromState(AppState state) {
final dateKeys = lastSevenDateKeys();
final dateKeySet = dateKeys.toSet();
final weekFocusMinutes = dateKeys.fold<int>(
0,
(total, key) => total + minutesForDate(state.sessions, key),
);
final activeDays = dateKeys.where((key) => _hasProgress(state, key)).length;
final habitCompletions = state.habits.fold<int>(
0,
(total, habit) =>
total + habit.completedDates.where(dateKeySet.contains).length,
);
final habitOpportunities = state.habits.length * dateKeys.length;
return JourneyStats(
currentStreakDays: currentStreak(
state.habits,
state.sessions,
state.reflections,
),
weekFocusMinutes: weekFocusMinutes,
activeDaysThisWeek: activeDays,
habitCompletionRate:
habitOpportunities == 0 ? 0 : habitCompletions / habitOpportunities,
);
}
static bool _hasProgress(AppState state, String key) {
return state.habits.any((habit) => habit.completedDates.contains(key)) ||
state.sessions.any((session) => dateKey(session.completedAt) == key) ||
state.reflections.any((reflection) => reflection.dateKey == key);
}
}
+6 -6
View File
@@ -1,6 +1,5 @@
import '../core/date_keys.dart';
import 'app_state.dart';
import 'growth_stage.dart';
class TodayProgress {
const TodayProgress({
@@ -10,7 +9,6 @@ class TodayProgress {
required this.completedHabits,
required this.totalHabits,
required this.hasReflection,
required this.growthStage,
});
factory TodayProgress.fromState(AppState state, String dateKey) {
@@ -23,8 +21,11 @@ class TodayProgress {
.where((habit) => habit.completedDates.contains(dateKey))
.length,
totalHabits: state.habits.length,
hasReflection: state.reflections.any((entry) => entry.dateKey == dateKey),
growthStage: growthStageFor(state, dateKey),
hasReflection:
state.reflections.any((entry) => entry.dateKey == dateKey) ||
state.progressNotes.any(
(entry) => entry.dateKey == dateKey && entry.hasContent,
),
);
}
@@ -34,16 +35,15 @@ class TodayProgress {
final int completedHabits;
final int totalHabits;
final bool hasReflection;
final GrowthStage growthStage;
bool get hasIntention => intention.isNotEmpty;
bool get hasFocus => focusMinutes > 0;
bool get hasHabits => completedHabits > 0;
bool get isLoopClosed => hasIntention && hasFocus && hasReflection;
int get completedSteps =>
[
hasIntention,
hasFocus,
hasHabits,
hasReflection,
].where((complete) => complete).length;
}
@@ -1,46 +0,0 @@
enum PremiumState { free, premium }
enum AdPlacement { statsBottom, reflectionComplete }
sealed class PurchaseResult {
const PurchaseResult();
static const unavailable = PurchaseUnavailable();
}
class PurchaseUnavailable extends PurchaseResult {
const PurchaseUnavailable();
}
abstract interface class MonetizationProvider {
bool get adsEnabled;
bool get billingEnabled;
Stream<PremiumState> premiumState();
Future<void> showInterstitialIfEligible(AdPlacement placement);
Future<PurchaseResult> startPurchase(String productId);
Future<void> restorePurchases();
}
class NoopMonetizationProvider implements MonetizationProvider {
const NoopMonetizationProvider();
@override
bool get adsEnabled => false;
@override
bool get billingEnabled => false;
@override
Stream<PremiumState> premiumState() => Stream.value(PremiumState.free);
@override
Future<void> showInterstitialIfEligible(AdPlacement placement) async {}
@override
Future<PurchaseResult> startPurchase(String productId) async {
return PurchaseResult.unavailable;
}
@override
Future<void> restorePurchases() async {}
}
+452 -148
View File
@@ -1,122 +1,262 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../app/app_theme.dart';
import '../l10n/l10n.dart';
import 'app_bottom_sheet.dart';
import 'app_busy_indicator.dart';
import 'surface_panel.dart';
Future<void> showHabitDialog(
BuildContext context,
ValueChanged<String> onSubmit, {
Future<bool> Function(String value) onSubmit, {
String initialValue = '',
}) async {
var value = initialValue;
await showDialog<void>(
final l10n = context.l10n;
await showTextEntrySheet(
context: context,
builder:
(context) => AlertDialog(
title: Text(
initialValue.isEmpty
? context.l10n.addHabitTitle
: context.l10n.renameHabitTitle,
),
content: TextFormField(
initialValue: initialValue,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: context.l10n.habitHint,
),
onChanged: (nextValue) => value = nextValue,
onFieldSubmitted: (value) {
onSubmit(value);
Navigator.pop(context);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onSubmit(value);
Navigator.pop(context);
},
child: Text(context.l10n.save),
),
],
),
title: initialValue.isEmpty ? l10n.addHabitTitle : l10n.renameHabitTitle,
hintText: l10n.habitHint,
initialValue: initialValue,
icon: Icons.add_task_rounded,
accentBuilder: (context) => FocusSeedVisuals.of(context).habit,
onSubmit: onSubmit,
);
}
Future<void> showIntentionDialog(
BuildContext context,
ValueChanged<String> onSubmit, {
Future<bool> Function(String value) onSubmit, {
String initialValue = '',
}) async {
var value = initialValue;
await showDialog<void>(
final l10n = context.l10n;
await showTextEntrySheet(
context: context,
title:
initialValue.isEmpty ? l10n.setIntentionTitle : l10n.editIntentionTitle,
hintText: l10n.intentionHint,
initialValue: initialValue,
icon: Icons.spa_rounded,
maxLength: 90,
maxLines: 3,
accentBuilder: (context) => FocusSeedVisuals.of(context).action,
onSubmit: onSubmit,
);
}
Future<void> showTextEntrySheet({
required BuildContext context,
required String title,
required String hintText,
required Future<bool> Function(String value) onSubmit,
required IconData icon,
required Color Function(BuildContext context) accentBuilder,
String initialValue = '',
int? maxLength,
int minLines = 1,
int maxLines = 1,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => AlertDialog(
title: Text(
initialValue.isEmpty
? context.l10n.setIntentionTitle
: context.l10n.editIntentionTitle,
),
content: TextFormField(
initialValue: initialValue,
autofocus: true,
maxLength: 90,
maxLines: 3,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(hintText: context.l10n.intentionHint),
onChanged: (nextValue) => value = nextValue,
onFieldSubmitted: (value) {
onSubmit(value);
Navigator.pop(context);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onSubmit(value);
Navigator.pop(context);
},
child: Text(context.l10n.save),
),
],
(context) => _TextEntrySheet(
title: title,
hintText: hintText,
initialValue: initialValue,
maxLength: maxLength,
minLines: minLines,
maxLines: maxLines,
icon: icon,
accent: accentBuilder(context),
onSubmit: onSubmit,
),
);
}
class _TextEntrySheet extends StatefulWidget {
const _TextEntrySheet({
required this.title,
required this.hintText,
required this.initialValue,
required this.icon,
required this.accent,
required this.onSubmit,
this.maxLength,
this.minLines = 1,
this.maxLines = 1,
});
final String title;
final String hintText;
final String initialValue;
final IconData icon;
final Color accent;
final Future<bool> Function(String value) onSubmit;
final int? maxLength;
final int minLines;
final int maxLines;
@override
State<_TextEntrySheet> createState() => _TextEntrySheetState();
}
class _TextEntrySheetState extends State<_TextEntrySheet> {
late final TextEditingController _controller;
bool _saving = false;
String? _errorText;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_saving) {
return;
}
final value = _controller.text.trim();
if (value.isEmpty) {
setState(() => _errorText = context.l10n.textEntryRequired);
return;
}
setState(() {
_saving = true;
_errorText = null;
});
final saved = await widget.onSubmit(value);
if (!mounted) {
return;
}
if (saved) {
Navigator.pop(context);
return;
}
setState(() {
_saving = false;
_errorText = context.l10n.saveFailed;
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final foreground =
ThemeData.estimateBrightnessForColor(widget.accent) == Brightness.dark
? Colors.white
: scheme.onSurface;
return AppBottomSheetScaffold(
title: widget.title,
icon: widget.icon,
accent: widget.accent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SurfacePanel(
radius: 18,
tone: SurfacePanelTone.paper,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: TextField(
controller: _controller,
autofocus: true,
maxLength: widget.maxLength,
minLines: widget.minLines,
maxLines: widget.maxLines,
textCapitalization: TextCapitalization.sentences,
textInputAction:
widget.maxLines == 1
? TextInputAction.done
: TextInputAction.newline,
onChanged: (_) {
if (_errorText == null) {
setState(() {});
return;
}
setState(() => _errorText = null);
},
onSubmitted: (_) {
if (widget.maxLines == 1) {
unawaited(_submit());
}
},
decoration: InputDecoration(
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: widget.hintText,
errorText: _errorText,
),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
),
const SizedBox(width: 10),
Expanded(
flex: 2,
child: FilledButton(
onPressed:
_saving || _controller.text.trim().isEmpty
? null
: _submit,
style: FilledButton.styleFrom(
backgroundColor: widget.accent,
foregroundColor: foreground,
minimumSize: const Size.fromHeight(50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
textStyle: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
child:
_saving
? AppBusyIndicator(
semanticLabel: l10n.savingLabel,
color: foreground,
)
: Text(l10n.save),
),
),
],
),
],
),
);
}
}
Future<void> confirmDeleteHabit(
BuildContext context,
VoidCallback onConfirm,
) async {
await showDialog<void>(
await showConfirmSheet(
context: context,
builder:
(context) => AlertDialog(
title: Text(context.l10n.deleteHabitTitle),
content: Text(context.l10n.deleteHabitBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: Text(context.l10n.delete),
),
],
),
title: context.l10n.deleteHabitTitle,
body: context.l10n.deleteHabitBody,
confirmLabel: context.l10n.delete,
icon: Icons.delete_outline_rounded,
accentBuilder: (context) => Theme.of(context).colorScheme.error,
onConfirm: onConfirm,
);
}
@@ -124,26 +264,14 @@ Future<void> confirmClearToday(
BuildContext context,
VoidCallback onConfirm,
) async {
await showDialog<void>(
await showConfirmSheet(
context: context,
builder:
(context) => AlertDialog(
title: Text(context.l10n.clearTodayDataTitle),
content: Text(context.l10n.clearTodayDataBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: Text(context.l10n.clearToday),
),
],
),
title: context.l10n.clearTodayDataTitle,
body: context.l10n.clearTodayDataBody,
confirmLabel: context.l10n.clearToday,
icon: Icons.refresh_rounded,
accentBuilder: (context) => FocusSeedVisuals.of(context).warning,
onConfirm: onConfirm,
);
}
@@ -151,60 +279,236 @@ Future<void> confirmClearData(
BuildContext context,
VoidCallback onConfirm,
) async {
await showDialog<void>(
await showConfirmSheet(
context: context,
title: context.l10n.clearAllDataTitle,
body: context.l10n.clearAllDataBody,
confirmLabel: context.l10n.deleteAll,
icon: Icons.delete_forever_rounded,
accentBuilder: (context) => Theme.of(context).colorScheme.error,
onConfirm: onConfirm,
);
}
Future<void> showConfirmSheet({
required BuildContext context,
required String title,
required String body,
required String confirmLabel,
required IconData icon,
required Color Function(BuildContext context) accentBuilder,
required VoidCallback onConfirm,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => AlertDialog(
title: Text(context.l10n.clearAllDataTitle),
content: Text(context.l10n.clearAllDataBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: Text(context.l10n.deleteAll),
),
],
(context) => _ConfirmSheet(
title: title,
body: body,
confirmLabel: confirmLabel,
icon: icon,
accent: accentBuilder(context),
onConfirm: onConfirm,
),
);
}
class _ConfirmSheet extends StatelessWidget {
const _ConfirmSheet({
required this.title,
required this.body,
required this.confirmLabel,
required this.icon,
required this.accent,
required this.onConfirm,
});
final String title;
final String body;
final String confirmLabel;
final IconData icon;
final Color accent;
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return AppBottomSheetScaffold(
title: title,
icon: icon,
accent: accent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SurfacePanel(
radius: 18,
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
color: Color.alphaBlend(
accent.withValues(alpha: 0.045),
scheme.surfaceContainerLowest,
),
child: Text(
body,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurface,
height: 1.42,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 14),
_ConfirmActions(
accent: accent,
confirmLabel: confirmLabel,
onConfirm: onConfirm,
),
],
),
);
}
}
void showPrivacySheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: ListView(
shrinkWrap: true,
(context) => AppBottomSheetScaffold(
title: context.l10n.privacyTitle,
subtitle: context.l10n.privacySubtitle,
icon: Icons.lock_outline_rounded,
accent: FocusSeedVisuals.of(context).privacy,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.privacyTitle,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
),
_PrivacyPoint(
icon: Icons.phone_android_rounded,
text: context.l10n.privacyBodyOne,
),
const SizedBox(height: 12),
Text(context.l10n.privacyBodyOne),
const SizedBox(height: 12),
Text(context.l10n.privacyBodyTwo),
_PrivacyPoint(
icon: Icons.cleaning_services_rounded,
text: context.l10n.privacyBodyTwo,
),
],
),
),
);
}
void showSnack(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
class _PrivacyPoint extends StatelessWidget {
const _PrivacyPoint({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final visuals = FocusSeedVisuals.of(context);
return SurfacePanel(
radius: 18,
padding: const EdgeInsets.fromLTRB(13, 13, 13, 13),
color: Color.alphaBlend(
visuals.privacy.withValues(alpha: 0.045),
scheme.surfaceContainerLowest,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: visuals.privacy, size: 21),
const SizedBox(width: 11),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurface,
height: 1.42,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
void showSnack(BuildContext context, String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.fromLTRB(16, 0, 16, 92),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
);
}
class _ConfirmActions extends StatelessWidget {
const _ConfirmActions({
required this.accent,
required this.confirmLabel,
required this.onConfirm,
});
final Color accent;
final String confirmLabel;
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final foreground =
ThemeData.estimateBrightnessForColor(accent) == Brightness.dark
? Colors.white
: scheme.onSurface;
final confirmButton = FilledButton(
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor: foreground,
minimumSize: const Size.fromHeight(50),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: Text(confirmLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
);
final cancelButton = TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
);
return LayoutBuilder(
builder: (context, constraints) {
final textScale = MediaQuery.textScalerOf(context).scale(1);
if (constraints.maxWidth < 330 || textScale > 1.25) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [confirmButton, const SizedBox(height: 8), cancelButton],
);
}
return Row(
children: [
Expanded(child: cancelButton),
const SizedBox(width: 10),
Expanded(flex: 2, child: confirmButton),
],
);
},
);
}
}
-145
View File
@@ -1,145 +0,0 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
class FocusSeedHeader extends StatelessWidget {
const FocusSeedHeader({
required this.title,
required this.subtitle,
this.showBrand = true,
this.isSaving = false,
this.trailing,
super.key,
});
final String title;
final String subtitle;
final bool showBrand;
final bool isSaving;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showBrand) ...[
LayoutBuilder(
builder: (context, constraints) {
final textScale =
MediaQuery.textScalerOf(context).scale(14) / 14;
final compact = constraints.maxWidth < 340 || textScale > 1.3;
return Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(
alpha: 0.12,
),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.spa_rounded,
color: theme.colorScheme.primary,
size: 21,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.brandName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child:
isSaving
? _SaveStatus(
key: const ValueKey('saving'),
icon: Icons.sync_rounded,
label: l10n.saving,
compact: compact,
)
: _SaveStatus(
key: const ValueKey('saved'),
icon: Icons.smartphone_outlined,
label: l10n.local,
compact: compact,
),
),
if (trailing != null) ...[
const SizedBox(width: 4),
trailing!,
],
],
);
},
),
const SizedBox(height: 12),
],
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(fontSize: 24),
),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class _SaveStatus extends StatelessWidget {
const _SaveStatus({
required this.icon,
required this.label,
required this.compact,
super.key,
});
final IconData icon;
final String label;
final bool compact;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Semantics(
label: label,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: theme.colorScheme.primary),
if (!compact) ...[
const SizedBox(width: 4),
Text(label, style: theme.textTheme.labelSmall),
],
],
),
),
);
}
}
-72
View File
@@ -1,72 +0,0 @@
import 'package:flutter/material.dart';
import '../app/app_theme.dart';
import '../l10n/l10n.dart';
import '../models/growth_stage.dart';
class GrowthObject extends StatelessWidget {
const GrowthObject({
required this.stage,
this.size = 148,
this.showAmbientHalo = true,
super.key,
});
final GrowthStage stage;
final double size;
final bool showAmbientHalo;
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.of(context).disableAnimations;
final visuals = FocusSeedVisuals.of(context);
final image = Image.asset(
stage.assetPath,
key: ValueKey(stage),
width: size,
height: size,
fit: BoxFit.contain,
gaplessPlayback: true,
semanticLabel: localizedGrowthSemantic(context.l10n, stage),
);
return SizedBox.square(
dimension: size,
child: Stack(
alignment: Alignment.center,
children: [
if (showAmbientHalo)
Container(
width: size * 0.76,
height: size * 0.76,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: visuals.ambientTint,
),
),
AnimatedSwitcher(
duration:
reduceMotion
? Duration.zero
: const Duration(milliseconds: 420),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
if (reduceMotion) {
return FadeTransition(opacity: animation, child: child);
}
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.94, end: 1.0).animate(animation),
child: child,
),
);
},
child: image,
),
],
),
);
}
}
+135 -1
View File
@@ -1,10 +1,144 @@
import 'package:flutter/material.dart';
import '../app/app_theme.dart';
import '../l10n/l10n.dart';
import 'app_motion.dart';
class LoadingView extends StatelessWidget {
const LoadingView({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: CircularProgressIndicator());
final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
final l10n = context.l10n;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_LoadingMark(color: visuals.focus),
const SizedBox(height: 14),
Text(
l10n.loadingLocalData,
textAlign: TextAlign.center,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w800,
),
),
],
),
);
}
}
class _LoadingMark extends StatefulWidget {
const _LoadingMark({required this.color});
final Color color;
@override
State<_LoadingMark> createState() => _LoadingMarkState();
}
class _LoadingMarkState extends State<_LoadingMark>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1100),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (AppMotion.reduced(context)) {
_controller.stop();
_controller.value = 1;
return;
}
if (!_controller.isAnimating) {
_controller.repeat(reverse: true);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (AppMotion.reduced(context)) {
return CustomPaint(
size: const Size.square(44),
painter: _LoadingMarkPainter(color: widget.color, progress: 1),
);
}
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final progress =
0.58 + Curves.easeInOut.transform(_controller.value) * 0.42;
return CustomPaint(
size: const Size.square(44),
painter: _LoadingMarkPainter(color: widget.color, progress: progress),
);
},
);
}
}
class _LoadingMarkPainter extends CustomPainter {
const _LoadingMarkPainter({required this.color, required this.progress});
final Color color;
final double progress;
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final seedRect = Rect.fromCenter(
center: center,
width: size.width * (0.52 + progress * 0.08),
height: size.height * 0.42,
);
canvas.drawOval(seedRect, Paint()..color = color.withValues(alpha: 0.13));
canvas.drawOval(
seedRect.deflate(1),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.8
..color = color.withValues(alpha: 0.28 + progress * 0.35),
);
final path =
Path()
..moveTo(size.width * 0.28, size.height * 0.54)
..quadraticBezierTo(
size.width * 0.48,
size.height * 0.40,
size.width * 0.72,
size.height * 0.48,
);
final metric = path.computeMetrics().first;
canvas.drawPath(
metric.extractPath(0, metric.length * progress.clamp(0.08, 1)),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.6
..strokeCap = StrokeCap.round
..color = color,
);
}
@override
bool shouldRepaint(covariant _LoadingMarkPainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.progress != progress;
}
}
-314
View File
@@ -1,314 +0,0 @@
import 'package:flutter/material.dart';
enum SeedIllustrationTone { garden, archive, data }
class SeedIllustration extends StatelessWidget {
const SeedIllustration({
this.tone = SeedIllustrationTone.garden,
this.size = 118,
this.hero = false,
super.key,
});
final SeedIllustrationTone tone;
final double size;
final bool hero;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final accent = switch (tone) {
SeedIllustrationTone.garden => scheme.primary,
SeedIllustrationTone.archive => scheme.secondary,
SeedIllustrationTone.data => scheme.tertiary,
};
final softAccent = accent.withValues(alpha: 0.18);
return ExcludeSemantics(
child: SizedBox.square(
dimension: size,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
softAccent.withValues(alpha: hero ? 0.92 : 1),
scheme.surfaceContainerHigh.withValues(alpha: 0.28),
Colors.transparent,
],
),
),
),
),
if (hero)
Positioned.fill(
child: CustomPaint(
painter: _OrbitPainter(
color: accent.withValues(alpha: 0.30),
quietColor: scheme.outlineVariant.withValues(alpha: 0.30),
),
),
),
if (hero)
Positioned(
left: size * 0.08,
top: size * 0.08,
child: _Sparkle(size: size * 0.13, color: accent),
),
if (hero)
Positioned(
right: size * 0.14,
bottom: size * 0.12,
child: _Sparkle(size: size * 0.10, color: scheme.tertiary),
),
Positioned(
bottom: size * 0.16,
child: Container(
width: size * (hero ? 0.72 : 0.62),
height: size * (hero ? 0.31 : 0.28),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(size),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: hero ? 0.16 : 0.10),
blurRadius: hero ? 26 : 18,
offset: Offset(0, hero ? 12 : 8),
),
],
),
),
),
Positioned(
top: size * (hero ? 0.15 : 0.18),
left: size * (hero ? 0.24 : 0.28),
child: Transform.rotate(
angle: -0.62,
child: _LeafBlob(
width: size * (hero ? 0.30 : 0.26),
height: size * (hero ? 0.48 : 0.42),
color: accent.withValues(alpha: 0.78),
),
),
),
Positioned(
top: size * (hero ? 0.12 : 0.16),
right: size * (hero ? 0.24 : 0.28),
child: Transform.rotate(
angle: 0.62,
child: _LeafBlob(
width: size * (hero ? 0.33 : 0.28),
height: size * (hero ? 0.52 : 0.45),
color: scheme.primaryContainer.withValues(alpha: 0.86),
),
),
),
if (hero)
Positioned(
top: size * 0.25,
right: size * 0.36,
child: Transform.rotate(
angle: 0.10,
child: _LeafBlob(
width: size * 0.18,
height: size * 0.30,
color: scheme.tertiary.withValues(alpha: 0.62),
),
),
),
Positioned(
top: size * 0.44,
child: Container(
width: size * (hero ? 0.09 : 0.08),
height: size * (hero ? 0.32 : 0.28),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(99),
),
),
),
Positioned(
bottom: size * 0.26,
child: Transform.rotate(
angle: 0.18,
child: Container(
width: size * (hero ? 0.21 : 0.18),
height: size * (hero ? 0.31 : 0.28),
decoration: BoxDecoration(
color: scheme.tertiaryContainer.withValues(alpha: 0.90),
borderRadius: BorderRadius.circular(99),
border: Border.all(
color: scheme.tertiary.withValues(alpha: 0.28),
),
boxShadow: [
BoxShadow(
color: accent.withValues(alpha: 0.22),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
),
),
),
Positioned(
right: size * 0.10,
top: size * 0.30,
child: _FloatingDot(size: size * 0.09, color: accent),
),
Positioned(
left: size * 0.12,
bottom: size * 0.34,
child: _FloatingDot(size: size * 0.065, color: scheme.secondary),
),
],
),
),
);
}
}
class _LeafBlob extends StatelessWidget {
const _LeafBlob({
required this.width,
required this.height,
required this.color,
});
final double width;
final double height;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(width),
topRight: Radius.circular(width * 0.35),
bottomLeft: Radius.circular(width * 0.35),
bottomRight: Radius.circular(width),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.10),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
);
}
}
class _FloatingDot extends StatelessWidget {
const _FloatingDot({required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: 0.42),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.22),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
);
}
}
class _Sparkle extends StatelessWidget {
const _Sparkle({required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: size,
child: CustomPaint(painter: _SparklePainter(color)),
);
}
}
class _SparklePainter extends CustomPainter {
const _SparklePainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color.withValues(alpha: 0.72);
final center = Offset(size.width / 2, size.height / 2);
final path =
Path()
..moveTo(center.dx, 0)
..quadraticBezierTo(
center.dx * 1.18,
center.dy * 0.78,
size.width,
center.dy,
)
..quadraticBezierTo(
center.dx * 1.18,
center.dy * 1.18,
center.dx,
size.height,
)
..quadraticBezierTo(center.dx * 0.78, center.dy * 1.18, 0, center.dy)
..quadraticBezierTo(center.dx * 0.78, center.dy * 0.78, center.dx, 0);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _SparklePainter oldDelegate) {
return oldDelegate.color != color;
}
}
class _OrbitPainter extends CustomPainter {
const _OrbitPainter({required this.color, required this.quietColor});
final Color color;
final Color quietColor;
@override
void paint(Canvas canvas, Size size) {
final rect =
Offset(size.width * 0.08, size.height * 0.12) &
Size(size.width * 0.84, size.height * 0.72);
final paint =
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.shortestSide * 0.018
..strokeCap = StrokeCap.round
..color = quietColor;
canvas.drawArc(rect, -0.12, 4.8, false, paint);
paint.color = color;
canvas.drawArc(rect, 2.8, 1.25, false, paint);
}
@override
bool shouldRepaint(covariant _OrbitPainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.quietColor != quietColor;
}
}
+95 -12
View File
@@ -1,31 +1,114 @@
import 'package:flutter/material.dart';
class SettingsTile extends StatelessWidget {
const SettingsTile({
import 'workbench_ui.dart';
class SettingsSection extends StatelessWidget {
const SettingsSection({
required this.title,
required this.children,
super.key,
});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
WorkbenchPanel(
accent: Theme.of(context).colorScheme.primary,
padding: EdgeInsets.zero,
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) const Divider(indent: 16, endIndent: 16, height: 1),
children[i],
],
],
),
),
],
);
}
}
class SettingsRow extends StatelessWidget {
const SettingsRow({
required this.icon,
required this.title,
required this.subtitle,
required this.color,
required this.onTap,
this.destructive = false,
super.key,
});
final IconData icon;
final String title;
final String subtitle;
final Color color;
final VoidCallback onTap;
final bool destructive;
@override
Widget build(BuildContext context) {
final color = destructive ? Theme.of(context).colorScheme.error : null;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: color),
title: Text(title, style: TextStyle(color: color)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: onTap,
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 13, 10, 13),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.11),
borderRadius: BorderRadius.circular(kWorkbenchRadius),
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
const Icon(Icons.chevron_right_rounded),
],
),
),
),
);
}
}
+62 -26
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import '../app/app_theme.dart';
import 'pressable_scale.dart';
enum SurfacePanelTone { plain, paper, wash }
class SurfacePanel extends StatelessWidget {
const SurfacePanel({
@@ -9,6 +12,8 @@ class SurfacePanel extends StatelessWidget {
this.margin,
this.color,
this.borderColor,
this.tone = SurfacePanelTone.plain,
this.radius = 12,
this.elevated = false,
this.onTap,
super.key,
@@ -19,6 +24,8 @@ class SurfacePanel extends StatelessWidget {
final EdgeInsetsGeometry? margin;
final Color? color;
final Color? borderColor;
final SurfacePanelTone tone;
final double radius;
final bool elevated;
final VoidCallback? onTap;
@@ -26,35 +33,64 @@ class SurfacePanel extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
final scheme = theme.colorScheme;
final effectiveColor =
color ??
switch (tone) {
SurfacePanelTone.plain => scheme.surfaceContainerLowest,
SurfacePanelTone.paper =>
scheme.brightness == Brightness.dark
? scheme.surfaceContainerLow
: AppTheme.paper,
SurfacePanelTone.wash =>
scheme.brightness == Brightness.dark
? scheme.surfaceContainerLow
: scheme.surfaceContainerLowest,
};
final effectiveBorderColor =
borderColor ??
(tone == SurfacePanelTone.wash
? visuals.focus.withValues(alpha: 0.06)
: scheme.outlineVariant.withValues(alpha: 0.0));
final decoration = BoxDecoration(
color: color ?? theme.colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: borderColor ?? theme.colorScheme.outlineVariant,
),
boxShadow:
elevated
? [
BoxShadow(
color: visuals.shadow,
blurRadius: 16,
offset: const Offset(0, 5),
),
]
: null,
color: effectiveColor,
borderRadius: BorderRadius.circular(radius),
border:
effectiveBorderColor.a == 0
? null
: Border.all(color: effectiveBorderColor),
boxShadow: [
BoxShadow(
color: visuals.shadow.withValues(
alpha:
theme.colorScheme.brightness == Brightness.dark
? elevated
? 0.22
: 0.11
: elevated
? 0.11
: 0.045,
),
blurRadius: elevated ? 22 : 12,
offset: Offset(0, elevated ? 8 : 4),
),
],
);
final content = Padding(padding: padding, child: child);
return Container(
margin: margin,
decoration: decoration,
clipBehavior: Clip.antiAlias,
child:
onTap == null
? content
: Material(
color: Colors.transparent,
child: InkWell(onTap: onTap, child: content),
),
return PressableScale(
enabled: onTap != null,
child: Container(
margin: margin,
decoration: decoration,
clipBehavior: Clip.antiAlias,
child:
onTap == null
? content
: Material(
color: Colors.transparent,
child: InkWell(onTap: onTap, child: content),
),
),
);
}
}