checkpoint: focus seed 1.0.1 localized dev switch

This commit is contained in:
2026-06-11 23:35:49 +08:00
parent c51abdfb73
commit d06dc35ee6
72 changed files with 6155 additions and 1390 deletions
+212 -24
View File
@@ -1,52 +1,240 @@
import 'package:flutter/material.dart';
import '../core/ambience.dart';
class AppTheme {
static const seed = Color(0xFF2E7D6B);
static const accent = Color(0xFFF4B740);
static const paper = Color(0xFFF7F8F5);
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 ThemeData light() {
final scheme = ColorScheme.fromSeed(seedColor: seed, surface: paper);
return _base(scheme);
}
static ThemeData dark() {
final scheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.dark,
static ThemeData light(DayPhase phase) {
final surface = switch (phase) {
DayPhase.dawn => const Color(0xFFF3F8F7),
DayPhase.day => porcelain,
DayPhase.dusk => const Color(0xFFFBF5F2),
DayPhase.night => const Color(0xFFF3F5F3),
};
final scheme = ColorScheme.fromSeed(seedColor: leaf).copyWith(
primary: leaf,
onPrimary: Colors.white,
secondary: ultramarine,
tertiary: coral,
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),
);
return _base(scheme);
return _base(scheme, phase);
}
static ThemeData _base(ColorScheme scheme) {
return ThemeData(
static ThemeData dark(DayPhase phase) {
final surface = switch (phase) {
DayPhase.dawn => const Color(0xFF141C1B),
DayPhase.day => const Color(0xFF151B18),
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),
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),
);
return _base(scheme, phase);
}
static ThemeData _base(ColorScheme scheme, DayPhase phase) {
final base = ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
navigationBarTheme: NavigationBarThemeData(
indicatorColor: scheme.primaryContainer,
labelTextStyle: WidgetStateProperty.resolveWith(
(_) => const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
);
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,
),
bodyLarge: base.textTheme.bodyLarge?.copyWith(height: 1.4),
bodyMedium: base.textTheme.bodyMedium?.copyWith(height: 1.4),
labelLarge: base.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
);
return base.copyWith(
textTheme: textTheme,
scaffoldBackgroundColor: scheme.surface,
cardTheme: CardTheme(
elevation: 0,
margin: EdgeInsets.zero,
color: scheme.surfaceContainerHighest.withValues(alpha: 0.55),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
color: scheme.surfaceContainerLowest,
shadowColor: ink.withValues(alpha: 0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: scheme.outlineVariant),
),
),
navigationBarTheme: NavigationBarThemeData(
height: 68,
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,
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(48, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(48, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 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)),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surface,
modalBackgroundColor: scheme.surface,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
),
extensions: [FocusSeedVisuals.forPhase(phase, scheme.brightness)],
);
}
}
@immutable
class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
const FocusSeedVisuals({
required this.ambientTint,
required this.reflection,
required this.success,
required this.shadow,
});
final Color ambientTint;
final Color reflection;
final Color success;
final Color shadow;
static FocusSeedVisuals of(BuildContext context) {
return Theme.of(context).extension<FocusSeedVisuals>()!;
}
factory FocusSeedVisuals.forPhase(DayPhase phase, Brightness brightness) {
final isDark = brightness == Brightness.dark;
final ambientTint = switch (phase) {
DayPhase.dawn => const Color(0xFFA7D9D2),
DayPhase.day => AppTheme.mint,
DayPhase.dusk => const Color(0xFFF1B4A8),
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,
shadow: Colors.black.withValues(alpha: isDark ? 0.32 : 0.10),
);
}
@override
FocusSeedVisuals copyWith({
Color? ambientTint,
Color? reflection,
Color? success,
Color? shadow,
}) {
return FocusSeedVisuals(
ambientTint: ambientTint ?? this.ambientTint,
reflection: reflection ?? this.reflection,
success: success ?? this.success,
shadow: shadow ?? this.shadow,
);
}
@override
FocusSeedVisuals lerp(covariant FocusSeedVisuals? other, double t) {
if (other == null) {
return this;
}
return FocusSeedVisuals(
ambientTint: Color.lerp(ambientTint, other.ambientTint, t)!,
reflection: Color.lerp(reflection, other.reflection, t)!,
success: Color.lerp(success, other.success, t)!,
shadow: Color.lerp(shadow, other.shadow, t)!,
);
}
}
+120 -7
View File
@@ -1,19 +1,132 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import '../core/ambience.dart';
import '../data/app_preferences_store.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 'app_theme.dart';
class FocusSeedApp extends StatelessWidget {
const FocusSeedApp({super.key});
@visibleForTesting
Locale focusSeedDefaultLocale({bool isRelease = kReleaseMode}) {
return isRelease ? const Locale('en') : const Locale('zh');
}
class FocusSeedApp extends StatefulWidget {
const FocusSeedApp({this.locale, super.key});
final Locale? locale;
@override
State<FocusSeedApp> createState() => _FocusSeedAppState();
}
class _FocusSeedAppState extends State<FocusSeedApp>
with WidgetsBindingObserver {
final _preferencesStore = AppPreferencesStore();
AppearancePreference _appearance = AppearancePreference.system;
LanguagePreference _language = LanguagePreference.defaultLocale;
DayPhase _dayPhase = dayPhaseFor(DateTime.now());
Timer? _phaseTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_loadPreferences();
_schedulePhaseRefresh();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_refreshDayPhase();
}
}
Future<void> _loadPreferences() async {
final appearance = await _preferencesStore.loadAppearance();
final language = await _preferencesStore.loadLanguage();
if (!mounted) {
return;
}
setState(() {
_appearance = appearance;
_language = language;
});
}
Future<void> _setAppearance(AppearancePreference appearance) async {
setState(() => _appearance = appearance);
await _preferencesStore.saveAppearance(appearance);
}
Future<void> _setLanguage(LanguagePreference language) async {
setState(() => _language = language);
await _preferencesStore.saveLanguage(language);
}
void _refreshDayPhase() {
final phase = dayPhaseFor(DateTime.now());
if (phase != _dayPhase && mounted) {
setState(() => _dayPhase = phase);
}
_schedulePhaseRefresh();
}
void _schedulePhaseRefresh() {
_phaseTimer?.cancel();
final now = DateTime.now();
final delay = nextDayPhaseBoundary(now).difference(now);
_phaseTimer = Timer(delay, _refreshDayPhase);
}
ThemeMode get _themeMode => switch (_appearance) {
AppearancePreference.system => ThemeMode.system,
AppearancePreference.light => ThemeMode.light,
AppearancePreference.dark => ThemeMode.dark,
};
Locale get _resolvedLocale {
return widget.locale ?? _language.locale ?? focusSeedDefaultLocale();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Focus Seed',
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
home: const FocusSeedHome(),
theme: AppTheme.light(_dayPhase),
darkTheme: AppTheme.dark(_dayPhase),
themeMode: _themeMode,
themeAnimationDuration: const Duration(milliseconds: 240),
locale: _resolvedLocale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: FocusSeedHome(
appearance: _appearance,
onAppearanceChanged: _setAppearance,
language: _language,
showLanguageTools: !kReleaseMode && widget.locale == null,
onLanguageChanged: _setLanguage,
),
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_phaseTimer?.cancel();
super.dispose();
}
}
@@ -0,0 +1,205 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../core/date_keys.dart';
import '../data/focus_seed_store.dart';
import '../models/app_state.dart';
import '../models/focus_session.dart';
import '../models/habit.dart';
import '../models/mood.dart';
import '../models/reflection_entry.dart';
import '../services/analytics_provider.dart';
enum FocusSeedError { openFailed, saveFailed }
class FocusSeedController extends ChangeNotifier {
FocusSeedController({
FocusSeedStore? store,
AnalyticsProvider? analytics,
List<String> defaultHabitTitles = const [],
}) : _store = store ?? FocusSeedStore(),
_analytics = analytics ?? const NoopAnalyticsProvider(),
_defaultHabitTitles = List.unmodifiable(defaultHabitTitles);
final FocusSeedStore _store;
final AnalyticsProvider _analytics;
List<String> _defaultHabitTitles;
AppState? _state;
bool _isLoading = true;
bool _isSaving = false;
FocusSeedError? _error;
AppState? get state => _state;
bool get isLoading => _isLoading;
bool get isSaving => _isSaving;
FocusSeedError? get error => _error;
void updateDefaultHabitTitles(List<String> titles) {
_defaultHabitTitles = List.unmodifiable(titles);
}
Future<void> load() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_state = await _store.load(defaultHabitTitles: _defaultHabitTitles);
} on Object {
_error = FocusSeedError.openFailed;
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<bool> setTodayAnchor(String value) async {
final current = _state;
final anchor = value.trim();
if (current == null || anchor.isEmpty) {
return false;
}
_analytics.track('intention_saved');
return _commit(current.copyWith(anchor: anchor, anchorDateKey: todayKey()));
}
Future<bool> toggleHabit(Habit habit) async {
final current = _state;
if (current == null) {
return false;
}
final today = todayKey();
final completedDates = Set<String>.from(habit.completedDates);
if (completedDates.contains(today)) {
completedDates.remove(today);
} else {
completedDates.add(today);
await HapticFeedback.lightImpact();
}
final habits = current.habits
.map(
(item) =>
item.id == habit.id
? habit.copyWith(completedDates: completedDates)
: item,
)
.toList(growable: false);
_analytics.track('habit_toggled');
return _commit(current.copyWith(habits: habits));
}
Future<bool> addHabit(String value) async {
final current = _state;
final title = value.trim();
if (current == null || title.isEmpty) {
return false;
}
final now = DateTime.now();
final habit = Habit(
id: 'habit-${now.microsecondsSinceEpoch}',
title: title,
createdAt: now,
completedDates: const {},
);
_analytics.track('habit_created');
return _commit(current.copyWith(habits: [...current.habits, habit]));
}
Future<bool> renameHabit(Habit habit, String value) async {
final current = _state;
final title = value.trim();
if (current == null || title.isEmpty) {
return false;
}
final habits = current.habits
.map((item) => item.id == habit.id ? item.copyWith(title: title) : item)
.toList(growable: false);
return _commit(current.copyWith(habits: habits));
}
Future<bool> deleteHabit(Habit habit) async {
final current = _state;
if (current == null) {
return false;
}
final habits = current.habits
.where((item) => item.id != habit.id)
.toList(growable: false);
return _commit(current.copyWith(habits: habits));
}
Future<bool> completeFocusSession(int minutes) async {
final current = _state;
if (current == null) {
return false;
}
final now = DateTime.now();
final session = FocusSession(
id: 'session-${now.microsecondsSinceEpoch}',
minutes: minutes,
completedAt: now,
);
_analytics.track('focus_completed', {'minutes': minutes});
return _commit(current.copyWith(sessions: [...current.sessions, session]));
}
Future<bool> saveReflection(String value, Mood mood) async {
final current = _state;
final note = value.trim();
if (current == null || note.isEmpty) {
return false;
}
final today = todayKey();
final now = DateTime.now();
final entry = ReflectionEntry(
id: 'reflection-${now.microsecondsSinceEpoch}',
dateKey: today,
note: note,
mood: mood,
createdAt: now,
);
final reflections = [
...current.reflections.where((item) => item.dateKey != today),
entry,
];
_analytics.track('reflection_saved');
return _commit(current.copyWith(reflections: reflections));
}
Future<bool> importState(String raw) async {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) {
return false;
}
return _commit(AppState.fromJson(decoded));
} on Object {
return false;
}
}
Future<bool> clearData() async {
return _commit(AppState.starter(defaultHabitTitles: _defaultHabitTitles));
}
Future<bool> _commit(AppState nextState) async {
final previousState = _state;
_state = nextState;
_isSaving = true;
_error = null;
notifyListeners();
try {
await _store.save(nextState);
return true;
} on Object {
_state = previousState;
_error = FocusSeedError.saveFailed;
return false;
} finally {
_isSaving = false;
notifyListeners();
}
}
}
@@ -0,0 +1,140 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
enum FocusTimerStatus { idle, running, paused, completed }
class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
FocusTimerController({
required Future<void> Function(int minutes) onCompleted,
int initialMinutes = 25,
}) : _onCompleted = onCompleted,
_selectedMinutes = initialMinutes,
_remainingSeconds = initialMinutes * 60 {
WidgetsBinding.instance.addObserver(this);
}
static const presets = [15, 25, 45];
final Future<void> Function(int minutes) _onCompleted;
Timer? _ticker;
DateTime? _endsAt;
int _selectedMinutes;
int _remainingSeconds;
FocusTimerStatus _status = FocusTimerStatus.idle;
bool _isCompleting = false;
int get selectedMinutes => _selectedMinutes;
int get remainingSeconds => _remainingSeconds;
FocusTimerStatus get status => _status;
bool get isRunning => _status == FocusTimerStatus.running;
bool get isPaused => _status == FocusTimerStatus.paused;
bool get isCompleted => _status == FocusTimerStatus.completed;
double get progress {
final totalSeconds = _selectedMinutes * 60;
return totalSeconds == 0
? 0
: (1 - (_remainingSeconds / totalSeconds)).clamp(0, 1);
}
void selectMinutes(int minutes) {
if (isRunning || !presets.contains(minutes)) {
return;
}
_selectedMinutes = minutes;
_remainingSeconds = minutes * 60;
_status = FocusTimerStatus.idle;
notifyListeners();
}
void start() {
if (isRunning || _isCompleting) {
return;
}
if (isCompleted || _remainingSeconds <= 0) {
_remainingSeconds = _selectedMinutes * 60;
}
_status = FocusTimerStatus.running;
_endsAt = DateTime.now().add(Duration(seconds: _remainingSeconds));
_startTicker();
notifyListeners();
}
void pause() {
if (!isRunning) {
return;
}
_refreshRemainingTime();
_ticker?.cancel();
_endsAt = null;
_status = FocusTimerStatus.paused;
notifyListeners();
}
void reset() {
_ticker?.cancel();
_endsAt = null;
_remainingSeconds = _selectedMinutes * 60;
_status = FocusTimerStatus.idle;
notifyListeners();
}
Future<void> completeNow() => _complete();
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && isRunning) {
_refreshRemainingTime();
if (_remainingSeconds > 0) {
_startTicker();
notifyListeners();
}
}
}
void _startTicker() {
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
_refreshRemainingTime();
if (_remainingSeconds <= 0) {
unawaited(_complete());
} else {
notifyListeners();
}
});
}
void _refreshRemainingTime() {
final endsAt = _endsAt;
if (endsAt == null) {
return;
}
final milliseconds = endsAt.difference(DateTime.now()).inMilliseconds;
_remainingSeconds = math.max(0, (milliseconds / 1000).ceil());
}
Future<void> _complete() async {
if (_isCompleting || isCompleted) {
return;
}
_isCompleting = true;
_ticker?.cancel();
_endsAt = null;
_remainingSeconds = 0;
_status = FocusTimerStatus.completed;
notifyListeners();
try {
await _onCompleted(_selectedMinutes);
} finally {
_isCompleting = false;
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_ticker?.cancel();
super.dispose();
}
}
+39
View File
@@ -0,0 +1,39 @@
enum DayPhase { dawn, day, dusk, night }
DayPhase dayPhaseFor(DateTime dateTime) {
final hour = dateTime.hour;
if (hour >= 5 && hour < 10) {
return DayPhase.dawn;
}
if (hour >= 10 && hour < 17) {
return DayPhase.day;
}
if (hour >= 17 && hour < 21) {
return DayPhase.dusk;
}
return DayPhase.night;
}
DateTime nextDayPhaseBoundary(DateTime dateTime) {
final day = DateTime(dateTime.year, dateTime.month, dateTime.day);
if (dateTime.hour < 5) {
return day.add(const Duration(hours: 5));
}
if (dateTime.hour < 10) {
return day.add(const Duration(hours: 10));
}
if (dateTime.hour < 17) {
return day.add(const Duration(hours: 17));
}
if (dateTime.hour < 21) {
return day.add(const Duration(hours: 21));
}
return day.add(const Duration(days: 1, hours: 5));
}
int recommendedFocusMinutes(DateTime dateTime) {
final isWeekend =
dateTime.weekday == DateTime.saturday ||
dateTime.weekday == DateTime.sunday;
return isWeekend ? 15 : 25;
}
-19
View File
@@ -11,25 +11,6 @@ String dateKey(DateTime date) {
'${local.day.toString().padLeft(2, '0')}';
}
String readableToday() {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final now = DateTime.now();
return '${months[now.month - 1]} ${now.day}, ${now.year}';
}
List<String> lastSevenDateKeys() {
final now = DateTime.now();
return List.generate(
+29
View File
@@ -0,0 +1,29 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../models/appearance_preference.dart';
import '../models/language_preference.dart';
class AppPreferencesStore {
static const _appearanceKey = 'focus_seed_appearance';
static const _languageKey = 'focus_seed_language';
Future<AppearancePreference> loadAppearance() async {
final preferences = await SharedPreferences.getInstance();
return appearancePreferenceFromName(preferences.getString(_appearanceKey));
}
Future<void> saveAppearance(AppearancePreference appearance) async {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(_appearanceKey, appearance.name);
}
Future<LanguagePreference> loadLanguage() async {
final preferences = await SharedPreferences.getInstance();
return languagePreferenceFromName(preferences.getString(_languageKey));
}
Future<void> saveLanguage(LanguagePreference language) async {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(_languageKey, language.name);
}
}
+3 -3
View File
@@ -8,16 +8,16 @@ import '../models/app_state.dart';
class FocusSeedStore {
static const _storageKey = 'focus_seed_state_v1';
Future<AppState> load() async {
Future<AppState> load({List<String>? defaultHabitTitles}) async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
if (raw == null || raw.isEmpty) {
return AppState.starter();
return AppState.starter(defaultHabitTitles: defaultHabitTitles);
}
try {
return AppState.fromJson(jsonDecode(raw) as Map<String, dynamic>);
} on Object {
return AppState.starter();
return AppState.starter(defaultHabitTitles: defaultHabitTitles);
}
}
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart';
import '../../l10n/l10n.dart';
import '../../widgets/dialogs.dart';
import '../today/widgets/compact_habit_row.dart';
Future<void> showHabitManagerSheet(
BuildContext context,
FocusSeedController controller,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => FractionallySizedBox(
heightFactor: 0.86,
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
final state = controller.state;
if (state == null) {
return const Center(child: CircularProgressIndicator());
}
final l10n = context.l10n;
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:
() =>
showHabitDialog(context, controller.addHabit),
icon: const Icon(Icons.add_rounded),
),
],
),
),
const Divider(),
Expanded(
child:
state.habits.isEmpty
? _EmptyHabits(
onAdd:
() => showHabitDialog(
context,
controller.addHabit,
),
)
: 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),
),
);
},
),
),
],
);
},
),
),
);
}
class _EmptyHabits extends StatelessWidget {
const _EmptyHabits({required this.onAdd});
final VoidCallback onAdd;
@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),
),
],
),
),
);
}
}
@@ -1,69 +0,0 @@
import 'package:flutter/material.dart';
import '../../core/date_keys.dart';
import '../../models/app_state.dart';
import '../../models/habit.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/empty_state.dart';
import '../../widgets/habit_row.dart';
class HabitsScreen extends StatelessWidget {
const HabitsScreen({
required this.state,
required this.onAddHabit,
required this.onRenameHabit,
required this.onDeleteHabit,
required this.onToggleHabit,
super.key,
});
final AppState state;
final ValueChanged<String> onAddHabit;
final void Function(Habit habit, String title) onRenameHabit;
final ValueChanged<Habit> onDeleteHabit;
final ValueChanged<Habit> onToggleHabit;
@override
Widget build(BuildContext context) {
final today = todayKey();
return AppScaffold(
title: 'Habits',
subtitle: 'Keep the list small and repeatable.',
action: IconButton.filledTonal(
tooltip: 'Add habit',
onPressed: () => showHabitDialog(context, onAddHabit),
icon: const Icon(Icons.add),
),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
if (state.habits.isEmpty)
EmptyState(
icon: Icons.checklist_outlined,
title: 'No habits yet',
action: 'Add habit',
onAction: () => showHabitDialog(context, onAddHabit),
)
else
...state.habits.map(
(habit) => HabitRow(
habit: habit,
dateKey: today,
onToggle: () => onToggleHabit(habit),
onEdit:
() => showHabitDialog(
context,
(title) => onRenameHabit(habit, title),
initialValue: habit.title,
),
onDelete:
() =>
confirmDeleteHabit(context, () => onDeleteHabit(habit)),
),
),
],
),
);
}
}
-147
View File
@@ -1,147 +0,0 @@
import 'package:flutter/material.dart';
import '../../app/app_theme.dart';
import '../../core/date_keys.dart';
import '../../models/app_state.dart';
import '../../models/habit.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/empty_state.dart';
import '../../widgets/habit_row.dart';
import '../../widgets/inline_editable_text.dart';
import '../../widgets/metric_tile.dart';
import '../../widgets/section_label.dart';
import '../../widgets/seed_card.dart';
import '../../widgets/weekly_progress.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({
required this.state,
required this.onAnchorChanged,
required this.onOpenTimer,
required this.onToggleHabit,
required this.onAddHabit,
super.key,
});
final AppState state;
final ValueChanged<String> onAnchorChanged;
final VoidCallback onOpenTimer;
final ValueChanged<Habit> onToggleHabit;
final ValueChanged<String> onAddHabit;
@override
Widget build(BuildContext context) {
final today = todayKey();
final completed =
state.habits
.where((habit) => habit.completedDates.contains(today))
.length;
final focusMinutes = minutesForDate(state.sessions, today);
final streak = currentStreak(
state.habits,
state.sessions,
state.reflections,
);
return AppScaffold(
title: 'Focus Seed',
subtitle: readableToday(),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel(
icon: Icons.flag_outlined,
text: 'Today anchor',
),
const SizedBox(height: 12),
InlineEditableText(
value: state.anchor,
emptyText: 'Choose one important task',
onSubmitted: onAnchorChanged,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onOpenTimer,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Start focus'),
),
],
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: MetricTile(
label: 'Focus',
value: '$focusMinutes',
suffix: 'min',
icon: Icons.timer_outlined,
),
),
const SizedBox(width: 10),
Expanded(
child: MetricTile(
label: 'Habits',
value: '$completed/${state.habits.length}',
suffix: '',
icon: Icons.check_circle_outline,
),
),
const SizedBox(width: 10),
Expanded(
child: MetricTile(
label: 'Streak',
value: '$streak',
suffix: 'days',
icon: Icons.local_fire_department_outlined,
accent: AppTheme.accent,
),
),
],
),
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Today habits',
style: Theme.of(context).textTheme.titleMedium,
),
IconButton.filledTonal(
tooltip: 'Add habit',
onPressed: () => showHabitDialog(context, onAddHabit),
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 8),
if (state.habits.isEmpty)
EmptyState(
icon: Icons.checklist_rtl,
title: 'Add one tiny habit',
action: 'Add habit',
onAction: () => showHabitDialog(context, onAddHabit),
)
else
...state.habits
.take(5)
.map(
(habit) => HabitRow(
habit: habit,
dateKey: today,
onToggle: () => onToggleHabit(habit),
),
),
const SizedBox(height: 18),
WeeklyProgress(state: state),
],
),
);
}
}
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart';
import '../../l10n/l10n.dart';
import '../../models/today_progress.dart';
import '../../widgets/focus_seed_header.dart';
import '../../widgets/surface_panel.dart';
import '../reflection/reflection_sheet.dart';
import 'widgets/growth_week_strip.dart';
import 'widgets/journey_timeline.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 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: [
JourneyTimeline(
progress: progress,
onReflect: () => showReflectionSheet(context, controller),
),
const SizedBox(height: 4),
GrowthWeekStrip(state: state),
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 _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,
),
),
],
);
}
}
@@ -0,0 +1,99 @@
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),
],
),
);
}
}
@@ -0,0 +1,181 @@
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;
}
+212
View File
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../l10n/l10n.dart';
import '../../models/appearance_preference.dart';
import '../../models/language_preference.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/focus_seed_header.dart';
import '../../widgets/settings_tile.dart';
import '../../widgets/surface_panel.dart';
class MeScreen extends StatelessWidget {
const MeScreen({
required this.controller,
required this.appearance,
required this.onAppearanceChanged,
required this.language,
required this.showLanguageTools,
required this.onLanguageChanged,
super.key,
});
final FocusSeedController controller;
final AppearancePreference appearance;
final ValueChanged<AppearancePreference> onAppearanceChanged;
final LanguagePreference language;
final bool showLanguageTools;
final ValueChanged<LanguagePreference> onLanguageChanged;
@override
Widget build(BuildContext context) {
final state = controller.state!;
final l10n = context.l10n;
return Column(
children: [
FocusSeedHeader(
title: l10n.meTitle,
subtitle: l10n.meSubtitle,
isSaving: controller.isSaving,
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 32),
children: [
Text(
l10n.appearance,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SurfacePanel(
child: SizedBox(
width: double.infinity,
child: SegmentedButton<AppearancePreference>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: AppearancePreference.system,
icon: const Icon(Icons.brightness_auto_outlined),
label: Text(l10n.system),
),
ButtonSegment(
value: AppearancePreference.light,
icon: const Icon(Icons.light_mode_outlined),
label: Text(l10n.light),
),
ButtonSegment(
value: AppearancePreference.dark,
icon: const Icon(Icons.dark_mode_outlined),
label: Text(l10n.dark),
),
],
selected: {appearance},
onSelectionChanged:
(selection) => onAppearanceChanged(selection.first),
),
),
),
if (showLanguageTools) ...[
const SizedBox(height: 18),
_SectionHeader(
title: l10n.language,
detail: l10n.developmentOnly,
),
const SizedBox(height: 8),
SurfacePanel(
child: SizedBox(
width: double.infinity,
child: SegmentedButton<LanguagePreference>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: LanguagePreference.defaultLocale,
icon: const Icon(Icons.tune_rounded),
label: Text(l10n.languageDefault),
),
ButtonSegment(
value: LanguagePreference.english,
icon: const Icon(Icons.language_rounded),
label: Text(l10n.languageEnglish),
),
ButtonSegment(
value: LanguagePreference.chinese,
icon: const Icon(Icons.translate_rounded),
label: Text(l10n.languageChinese),
),
],
selected: {language},
onSelectionChanged:
(selection) => onLanguageChanged(selection.first),
),
),
),
],
const SizedBox(height: 18),
Text(
l10n.yourData,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SurfacePanel(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Column(
children: [
SettingsTile(
icon: Icons.ios_share_outlined,
title: l10n.exportJson,
subtitle: l10n.exportJsonSubtitle,
onTap: () => showExportDialog(context, state),
),
const Divider(),
SettingsTile(
icon: Icons.file_upload_outlined,
title: l10n.importJson,
subtitle: l10n.importJsonSubtitle,
onTap:
() => showImportDialog(context, (raw) async {
final imported = await controller.importState(raw);
if (context.mounted) {
showSnack(
context,
imported
? l10n.dataImported
: l10n.importFailed,
);
}
}),
),
const Divider(),
SettingsTile(
icon: Icons.privacy_tip_outlined,
title: l10n.privacyTitle,
subtitle: l10n.privacySubtitle,
onTap: () => showPrivacySheet(context),
),
],
),
),
const SizedBox(height: 12),
SurfacePanel(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: SettingsTile(
icon: Icons.delete_outline,
title: l10n.resetLocalGarden,
subtitle: l10n.resetLocalGardenSubtitle,
destructive: true,
onTap:
() => confirmClearData(context, () async {
final cleared = await controller.clearData();
if (context.mounted && cleared) {
showSnack(context, l10n.localDataCleared);
}
}),
),
),
const SizedBox(height: 18),
Text(
'Focus Seed 1.0.1',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader({required this.title, required this.detail});
final String title;
final String detail;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(child: Text(title, style: theme.textTheme.titleMedium)),
Text(
detail,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
@@ -1,123 +0,0 @@
import 'package:flutter/material.dart';
import '../../core/date_keys.dart';
import '../../core/iterable_extensions.dart';
import '../../models/app_state.dart';
import '../../models/mood.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/section_label.dart';
import '../../widgets/seed_card.dart';
class ReflectionScreen extends StatefulWidget {
const ReflectionScreen({
required this.state,
required this.onSave,
super.key,
});
final AppState state;
final void Function(String note, Mood mood) onSave;
@override
State<ReflectionScreen> createState() => _ReflectionScreenState();
}
class _ReflectionScreenState extends State<ReflectionScreen> {
final _controller = TextEditingController();
Mood _mood = Mood.clear;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final today = todayKey();
final todayReflection =
widget.state.reflections
.where((item) => item.dateKey == today)
.firstOrNull;
if (todayReflection != null && _controller.text.isEmpty) {
_controller.text = todayReflection.note;
_mood = todayReflection.mood;
}
return AppScaffold(
title: 'Reflection',
subtitle: 'One sentence is enough.',
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel(
icon: Icons.psychology_outlined,
text: 'How did it feel?',
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children:
Mood.values
.map(
(mood) => ChoiceChip(
avatar: Icon(mood.icon, size: 18),
label: Text(mood.label),
selected: _mood == mood,
onSelected: (_) => setState(() => _mood = mood),
),
)
.toList(),
),
const SizedBox(height: 14),
TextField(
controller: _controller,
minLines: 4,
maxLines: 7,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'What made today a little better?',
),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: () => widget.onSave(_controller.text, _mood),
icon: const Icon(Icons.save_outlined),
label: const Text('Save reflection'),
),
],
),
),
const SizedBox(height: 18),
Text('Recent notes', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (widget.state.reflections.isEmpty)
const SeedCard(
child: Text(
'Your reflections will appear here after you save one.',
),
)
else
...widget.state.reflections.reversed
.take(7)
.map(
(entry) => SeedCard(
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(entry.mood.icon),
title: Text(entry.note),
subtitle: Text(entry.dateKey),
),
),
),
],
),
);
}
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.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';
Future<void> showReflectionSheet(
BuildContext context,
FocusSeedController controller,
) {
final state = controller.state;
if (state == null) {
return Future.value();
}
final existing =
state.reflections
.where((entry) => entry.dateKey == todayKey())
.firstOrNull;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => _ReflectionComposer(
initialNote: existing?.note ?? '',
initialMood: existing?.mood ?? Mood.clear,
onSave: controller.saveReflection,
),
);
}
class _ReflectionComposer extends StatefulWidget {
const _ReflectionComposer({
required this.initialNote,
required this.initialMood,
required this.onSave,
});
final String initialNote;
final Mood initialMood;
final Future<bool> Function(String note, Mood mood) onSave;
@override
State<_ReflectionComposer> createState() => _ReflectionComposerState();
}
class _ReflectionComposerState extends State<_ReflectionComposer> {
late final TextEditingController _controller;
late Mood _mood;
bool _saving = false;
String? _errorText;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialNote);
_mood = widget.initialMood;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _save() async {
final l10n = context.l10n;
if (_controller.text.trim().isEmpty) {
setState(() => _errorText = l10n.reflectionEmptyError);
return;
}
setState(() {
_saving = true;
_errorText = null;
});
final saved = await widget.onSave(_controller.text, _mood);
if (!mounted) {
return;
}
if (saved) {
Navigator.pop(context);
return;
}
setState(() {
_saving = false;
_errorText = l10n.reflectionSaveError;
});
}
@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,
),
),
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(
controller: _controller,
autofocus: widget.initialNote.isEmpty,
minLines: 4,
maxLines: 7,
maxLength: 280,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
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),
),
),
],
),
),
);
}
}
@@ -1,70 +0,0 @@
import 'package:flutter/material.dart';
import '../../models/app_state.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/seed_card.dart';
import '../../widgets/settings_tile.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({
required this.state,
required this.onImport,
required this.onClear,
super.key,
});
final AppState state;
final ValueChanged<String> onImport;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return AppScaffold(
title: 'Settings',
subtitle: 'Private by default. No account required.',
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
SeedCard(
child: Column(
children: [
SettingsTile(
icon: Icons.ios_share,
title: 'Export JSON',
subtitle: 'Copy your local data as readable JSON.',
onTap: () => showExportDialog(context, state),
),
const Divider(),
SettingsTile(
icon: Icons.file_upload_outlined,
title: 'Import JSON',
subtitle: 'Paste a previous Focus Seed export.',
onTap: () => showImportDialog(context, onImport),
),
const Divider(),
SettingsTile(
icon: Icons.privacy_tip_outlined,
title: 'Privacy',
subtitle:
'Your data stays on this device unless you export it.',
onTap: () => showPrivacySheet(context),
),
],
),
),
const SizedBox(height: 14),
SeedCard(
child: SettingsTile(
icon: Icons.delete_outline,
title: 'Clear local data',
subtitle: 'Reset habits, sessions, and reflections.',
destructive: true,
onTap: () => confirmClearData(context, onClear),
),
),
],
),
);
}
}
+148 -233
View File
@@ -1,269 +1,184 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/date_keys.dart';
import '../../data/focus_seed_store.dart';
import '../../models/app_state.dart';
import '../../models/focus_session.dart';
import '../../models/habit.dart';
import '../../models/mood.dart';
import '../../models/reflection_entry.dart';
import '../../services/analytics_provider.dart';
import '../../widgets/dialogs.dart';
import '../../application/focus_seed_controller.dart';
import '../../application/focus_timer_controller.dart';
import '../../l10n/l10n.dart';
import '../../models/appearance_preference.dart';
import '../../models/language_preference.dart';
import '../../widgets/loading_view.dart';
import '../habits/habits_screen.dart';
import '../home/home_screen.dart';
import '../reflection/reflection_screen.dart';
import '../settings/settings_screen.dart';
import '../timer/timer_screen.dart';
import '../journey/journey_screen.dart';
import '../me/me_screen.dart';
import '../today/today_screen.dart';
class FocusSeedHome extends StatefulWidget {
const FocusSeedHome({super.key});
const FocusSeedHome({
required this.appearance,
required this.onAppearanceChanged,
required this.language,
required this.showLanguageTools,
required this.onLanguageChanged,
super.key,
});
final AppearancePreference appearance;
final ValueChanged<AppearancePreference> onAppearanceChanged;
final LanguagePreference language;
final bool showLanguageTools;
final ValueChanged<LanguagePreference> onLanguageChanged;
@override
State<FocusSeedHome> createState() => _FocusSeedHomeState();
}
class _FocusSeedHomeState extends State<FocusSeedHome> {
final _store = FocusSeedStore();
final _analytics = const NoopAnalyticsProvider();
AppState? _state;
FocusSeedController? _controller;
FocusTimerController? _timerController;
int _tabIndex = 0;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final state = await _store.load();
if (!mounted) {
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller != null) {
return;
}
setState(() => _state = state);
}
Future<void> _setStateAndSave(AppState state) async {
setState(() => _state = state);
await _store.save(state);
_controller = FocusSeedController(
defaultHabitTitles: localizedStarterHabits(context.l10n),
);
_timerController = FocusTimerController(
onCompleted: (minutes) async {
final saved = await _controller!.completeFocusSession(minutes);
if (saved) {
await HapticFeedback.mediumImpact();
}
},
);
_controller!.load();
}
@override
Widget build(BuildContext context) {
final state = _state;
return Scaffold(
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child:
state == null
? const LoadingView()
: SafeArea(
child: IndexedStack(
index: _tabIndex,
final controller = _controller;
final timerController = _timerController;
if (controller == null || timerController == null) {
return const Scaffold(body: SafeArea(child: LoadingView()));
}
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
controller.updateDefaultHabitTitles(
localizedStarterHabits(context.l10n),
);
final state = controller.state;
if (controller.isLoading) {
return const Scaffold(body: SafeArea(child: LoadingView()));
}
if (state == null) {
final l10n = context.l10n;
return Scaffold(
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
HomeScreen(
state: state,
onAnchorChanged:
(anchor) => _setStateAndSave(
state.copyWith(anchor: anchor.trim()),
),
onOpenTimer: () => setState(() => _tabIndex = 1),
onToggleHabit: _toggleHabit,
onAddHabit: _addHabit,
const Icon(Icons.error_outline_rounded, size: 42),
const SizedBox(height: 12),
Text(
_messageForError(l10n, controller.error) ??
l10n.couldNotOpenApp,
textAlign: TextAlign.center,
),
TimerScreen(
onComplete: _completeFocusSession,
onReflect: () => setState(() => _tabIndex = 3),
),
HabitsScreen(
state: state,
onAddHabit: _addHabit,
onRenameHabit: _renameHabit,
onDeleteHabit: _deleteHabit,
onToggleHabit: _toggleHabit,
),
ReflectionScreen(state: state, onSave: _saveReflection),
SettingsScreen(
state: state,
onImport: _importState,
onClear: _clearData,
const SizedBox(height: 16),
FilledButton.icon(
onPressed: controller.load,
icon: const Icon(Icons.refresh_rounded),
label: Text(l10n.tryAgain),
),
],
),
),
),
bottomNavigationBar:
state == null
? null
: NavigationBar(
selectedIndex: _tabIndex,
onDestinationSelected: (index) {
HapticFeedback.selectionClick();
setState(() => _tabIndex = index);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Today',
),
),
);
}
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,
),
NavigationDestination(
icon: Icon(Icons.timer_outlined),
selectedIcon: Icon(Icons.timer),
label: 'Timer',
),
NavigationDestination(
icon: Icon(Icons.check_circle_outline),
selectedIcon: Icon(Icons.check_circle),
label: 'Habits',
),
NavigationDestination(
icon: Icon(Icons.edit_note_outlined),
selectedIcon: Icon(Icons.edit_note),
label: 'Reflect',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
JourneyScreen(controller: controller),
MeScreen(
controller: controller,
appearance: widget.appearance,
onAppearanceChanged: widget.onAppearanceChanged,
language: widget.language,
showLanguageTools: widget.showLanguageTools,
onLanguageChanged: widget.onLanguageChanged,
),
],
),
),
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,
),
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,
),
],
),
),
);
},
);
}
Future<void> _toggleHabit(Habit habit) async {
final state = _state;
if (state == null) {
return;
}
final today = todayKey();
final completedDates = Set<String>.from(habit.completedDates);
if (completedDates.contains(today)) {
completedDates.remove(today);
} else {
completedDates.add(today);
await HapticFeedback.lightImpact();
}
final habits =
state.habits
.map(
(item) =>
item.id == habit.id
? habit.copyWith(completedDates: completedDates)
: item,
)
.toList();
_analytics.track('habit_toggled');
await _setStateAndSave(state.copyWith(habits: habits));
}
Future<void> _addHabit(String title) async {
final state = _state;
final clean = title.trim();
if (state == null || clean.isEmpty) {
return;
}
final habit = Habit(
id: 'habit-${DateTime.now().microsecondsSinceEpoch}',
title: clean,
createdAt: DateTime.now(),
completedDates: {},
);
_analytics.track('habit_created');
await _setStateAndSave(state.copyWith(habits: [...state.habits, habit]));
}
Future<void> _renameHabit(Habit habit, String title) async {
final state = _state;
final clean = title.trim();
if (state == null || clean.isEmpty) {
return;
}
final habits =
state.habits
.map(
(item) =>
item.id == habit.id ? item.copyWith(title: clean) : item,
)
.toList();
await _setStateAndSave(state.copyWith(habits: habits));
}
Future<void> _deleteHabit(Habit habit) async {
final state = _state;
if (state == null) {
return;
}
final habits = state.habits.where((item) => item.id != habit.id).toList();
await _setStateAndSave(state.copyWith(habits: habits));
}
Future<void> _completeFocusSession(int minutes) async {
final state = _state;
if (state == null) {
return;
}
final session = FocusSession(
id: 'session-${DateTime.now().microsecondsSinceEpoch}',
minutes: minutes,
completedAt: DateTime.now(),
);
_analytics.track('focus_completed', {'minutes': minutes});
await _setStateAndSave(
state.copyWith(sessions: [...state.sessions, session]),
);
}
Future<void> _saveReflection(String note, Mood mood) async {
final state = _state;
final clean = note.trim();
if (state == null || clean.isEmpty) {
return;
}
final today = todayKey();
final entry = ReflectionEntry(
id: 'reflection-${DateTime.now().microsecondsSinceEpoch}',
dateKey: today,
note: clean,
mood: mood,
createdAt: DateTime.now(),
);
final reflections = [
...state.reflections.where((item) => item.dateKey != today),
entry,
];
_analytics.track('reflection_saved');
await _setStateAndSave(state.copyWith(reflections: reflections));
if (mounted) {
showSnack(context, 'Reflection saved');
}
}
Future<void> _importState(String raw) async {
try {
final imported = AppState.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
await _setStateAndSave(imported);
if (mounted) {
showSnack(context, 'Data imported');
}
} on Object {
if (mounted) {
showSnack(context, 'Import failed. Check the JSON and try again.');
}
}
}
Future<void> _clearData() async {
await _store.clear();
await _setStateAndSave(AppState.starter());
if (mounted) {
showSnack(context, 'Local data cleared');
}
@override
void dispose() {
_timerController?.dispose();
_controller?.dispose();
super.dispose();
}
}
String? _messageForError(AppLocalizations l10n, FocusSeedError? error) {
return switch (error) {
FocusSeedError.openFailed => l10n.openFailed,
FocusSeedError.saveFailed => l10n.saveFailed,
null => null,
};
}
-212
View File
@@ -1,212 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/date_keys.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/seed_card.dart';
class TimerScreen extends StatefulWidget {
const TimerScreen({
required this.onComplete,
required this.onReflect,
super.key,
});
final ValueChanged<int> onComplete;
final VoidCallback onReflect;
@override
State<TimerScreen> createState() => _TimerScreenState();
}
class _TimerScreenState extends State<TimerScreen> {
static const _presets = [15, 25, 45];
Timer? _timer;
int _selectedMinutes = 25;
int _remainingSeconds = 25 * 60;
bool _running = false;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final totalSeconds = _selectedMinutes * 60;
final progress = 1 - (_remainingSeconds / totalSeconds);
return AppScaffold(
title: 'Focus timer',
subtitle: 'No ads or distractions here.',
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
children: [
SeedCard(
child: Column(
children: [
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
children:
_presets
.map(
(minutes) => ChoiceChip(
label: Text('$minutes min'),
selected: _selectedMinutes == minutes,
onSelected:
_running
? null
: (_) => setState(() {
_selectedMinutes = minutes;
_remainingSeconds = minutes * 60;
}),
),
)
.toList(),
),
const SizedBox(height: 30),
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: progress.clamp(0, 1)),
duration: const Duration(milliseconds: 240),
builder:
(context, value, child) => SizedBox.square(
dimension: 220,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: value,
strokeWidth: 14,
strokeCap: StrokeCap.round,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
child!,
],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatSeconds(_remainingSeconds),
style: Theme.of(context).textTheme.displayMedium
?.copyWith(fontWeight: FontWeight.w800),
),
Text(
_running ? 'Focus in progress' : 'Ready when you are',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 28),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _running ? _pause : _start,
icon: Icon(_running ? Icons.pause : Icons.play_arrow),
label: Text(_running ? 'Pause' : 'Start'),
),
),
const SizedBox(width: 10),
IconButton.filledTonal(
tooltip: 'Reset timer',
onPressed: _reset,
icon: const Icon(Icons.restart_alt),
),
],
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _completeNow,
icon: const Icon(Icons.check),
label: const Text('Mark session complete'),
),
],
),
),
const SizedBox(height: 16),
SeedCard(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.edit_note_outlined),
title: const Text('Finish with a one-minute reflection'),
subtitle: const Text('Capture what worked while it is fresh.'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: widget.onReflect,
),
),
],
),
);
}
void _start() {
if (_running) {
return;
}
HapticFeedback.selectionClick();
setState(() => _running = true);
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_remainingSeconds <= 1) {
_completeNow();
return;
}
setState(() => _remainingSeconds -= 1);
});
}
void _pause() {
_timer?.cancel();
setState(() => _running = false);
}
void _reset() {
_timer?.cancel();
setState(() {
_running = false;
_remainingSeconds = _selectedMinutes * 60;
});
}
void _completeNow() {
_timer?.cancel();
final completedMinutes = _selectedMinutes;
setState(() {
_running = false;
_remainingSeconds = _selectedMinutes * 60;
});
HapticFeedback.mediumImpact();
widget.onComplete(completedMinutes);
showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Session saved'),
content: const Text(
'Take one minute to write what helped you focus.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Later'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
widget.onReflect();
},
child: const Text('Reflect'),
),
],
),
);
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:flutter/material.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/today_progress.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/focus_seed_header.dart';
import '../../widgets/surface_panel.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,
super.key,
});
final FocusSeedController controller;
final FocusTimerController timerController;
@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;
void editIntention() {
showIntentionDialog(
context,
controller.setTodayAnchor,
initialValue: progress.intention,
);
}
return Column(
children: [
FocusSeedHeader(
title: localizedGreeting(l10n, now),
subtitle: localizedReadableDate(context, now),
isSaving: controller.isSaving,
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 32),
children: [
if (controller.error != null) ...[
_InlineError(
message: _messageForError(l10n, controller.error!),
),
const SizedBox(height: 10),
],
IntentionCard(
intention: progress.intention,
onEdit: editIntention,
),
const SizedBox(height: 12),
FocusSessionCard(
controller: timerController,
growthStage: progress.growthStage,
hasIntention: progress.hasIntention,
onSetIntention: editIntention,
onReflect: () => showReflectionSheet(context, controller),
),
const SizedBox(height: 12),
SurfacePanel(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
child: DailyProgressRail(progress: progress),
),
const SizedBox(height: 12),
HabitSection(
habits: state.habits,
dateKey: today,
onToggle: controller.toggleHabit,
onAdd: () => showHabitDialog(context, controller.addHabit),
onManage: () => showHabitManagerSheet(context, controller),
),
const SizedBox(height: 12),
ReflectionPrompt(
reflection: reflection,
onTap: () => showReflectionSheet(context, controller),
),
const SizedBox(height: 12),
],
),
),
],
);
}
}
String _messageForError(AppLocalizations l10n, FocusSeedError error) {
return switch (error) {
FocusSeedError.openFailed => l10n.openFailed,
FocusSeedError.saveFailed => l10n.saveFailed,
};
}
class _InlineError extends StatelessWidget {
const _InlineError({required this.message});
final String message;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Semantics(
liveRegion: true,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.error_outline, color: scheme.onErrorContainer),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: TextStyle(color: scheme.onErrorContainer),
),
),
],
),
),
);
}
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../models/habit.dart';
class CompactHabitRow extends StatelessWidget {
const CompactHabitRow({
required this.habit,
required this.dateKey,
required this.onToggle,
this.onEdit,
this.onDelete,
super.key,
});
final Habit habit;
final String dateKey;
final VoidCallback onToggle;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final complete = habit.completedDates.contains(dateKey);
return Semantics(
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: 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,
),
),
),
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),
),
],
),
],
),
),
),
);
}
}
@@ -0,0 +1,212 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart';
class DailyProgressRail extends StatelessWidget {
const DailyProgressRail({required this.progress, super.key});
final TodayProgress progress;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final items = [
_ProgressItem(
label: l10n.progressIntention,
detail: progress.hasIntention ? l10n.done : l10n.next,
icon: Icons.flag_outlined,
complete: progress.hasIntention,
),
_ProgressItem(
label: l10n.progressFocus,
detail:
progress.hasFocus
? l10n.minutesShort(progress.focusMinutes)
: l10n.next,
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,
complete: progress.hasReflection,
),
];
final textScale = MediaQuery.textScalerOf(context).scale(12) / 12;
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 320 || textScale > 1.35) {
return _VerticalProgress(items: items);
}
return _HorizontalProgress(items: items);
},
);
}
}
class _HorizontalProgress extends StatelessWidget {
const _HorizontalProgress({required this.items});
final List<_ProgressItem> items;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Semantics(
label: context.l10n.dailyStepsComplete(
items.where((item) => item.complete).length,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
),
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,
),
),
_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,
),
),
],
),
),
],
),
);
}
}
class _VerticalProgress extends StatelessWidget {
const _VerticalProgress({required this.items});
final List<_ProgressItem> items;
@override
Widget build(BuildContext context) {
return Column(
children: [
for (var index = 0; index < items.length; index++) ...[
Row(
children: [
_ProgressDot(item: items[index]),
const SizedBox(width: 10),
Expanded(
child: Text(
items[index].label,
style: Theme.of(context).textTheme.labelLarge,
),
),
Text(items[index].detail),
],
),
if (index < items.length - 1) const SizedBox(height: 10),
],
],
);
}
}
class _ProgressDot extends StatelessWidget {
const _ProgressDot({required this.item});
final _ProgressItem item;
@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:
item.complete
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
),
child: Icon(
item.complete ? Icons.check_rounded : item.icon,
size: 17,
color:
item.complete
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
);
}
}
class _ProgressItem {
const _ProgressItem({
required this.label,
required this.detail,
required this.icon,
required this.complete,
});
final String label;
final String detail;
final IconData icon;
final bool complete;
}
@@ -0,0 +1,266 @@
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),
),
],
],
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../models/habit.dart';
import '../../../widgets/surface_panel.dart';
import 'compact_habit_row.dart';
class HabitSection extends StatelessWidget {
const HabitSection({
required this.habits,
required this.dateKey,
required this.onToggle,
required this.onAdd,
required this.onManage,
super.key,
});
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 completed =
habits.where((habit) => habit.completedDates.contains(dateKey)).length;
return SurfacePanel(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 8),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.todaysHabits,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
l10n.completedCount(completed, habits.length),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
tooltip: l10n.manageHabits,
onPressed: onManage,
icon: const Icon(Icons.tune_rounded),
),
IconButton.filledTonal(
tooltip: l10n.addHabit,
onPressed: onAdd,
icon: const Icon(Icons.add_rounded),
),
],
),
const SizedBox(height: 4),
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,
),
const SizedBox(width: 10),
Expanded(child: Text(l10n.emptyHabitPrompt)),
TextButton(onPressed: onAdd, child: Text(l10n.add)),
],
),
)
else
for (var index = 0; index < habits.take(4).length; index++) ...[
if (index > 0) const Divider(),
CompactHabitRow(
habit: habits[index],
dateKey: dateKey,
onToggle: () => onToggle(habits[index]),
),
],
if (habits.length > 4) ...[
const Divider(),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: onManage,
icon: const Icon(Icons.arrow_forward_rounded, size: 18),
label: Text(l10n.viewAllHabits(habits.length)),
),
),
],
],
),
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../widgets/surface_panel.dart';
class IntentionCard extends StatelessWidget {
const IntentionCard({
required this.intention,
required this.onEdit,
super.key,
});
final String intention;
final VoidCallback onEdit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final isEmpty = intention.isEmpty;
return SurfacePanel(
elevated: !isEmpty,
onTap: onEdit,
padding: const EdgeInsets.all(14),
child: Row(
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,
),
),
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: 8),
Icon(
isEmpty ? Icons.add_rounded : Icons.edit_outlined,
color: theme.colorScheme.onSurfaceVariant,
size: 21,
),
],
),
);
}
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart';
import '../../../models/reflection_entry.dart';
import '../../../widgets/surface_panel.dart';
class ReflectionPrompt extends StatelessWidget {
const ReflectionPrompt({
required this.reflection,
required this.onTap,
super.key,
});
final ReflectionEntry? reflection;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final visuals = FocusSeedVisuals.of(context);
final isComplete = reflection != null;
return SurfacePanel(
borderColor: visuals.reflection.withValues(alpha: 0.35),
color: visuals.reflection.withValues(alpha: 0.06),
onTap: onTap,
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: visuals.reflection.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isComplete ? Icons.local_florist_rounded : Icons.edit_outlined,
color: visuals.reflection,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isComplete
? l10n.reflectionSaved
: l10n.reflectionPromptTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 3),
Text(
isComplete ? reflection!.note : l10n.reflectionPromptBody,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
isComplete ? Icons.edit_outlined : Icons.arrow_forward_rounded,
color: visuals.reflection,
),
],
),
);
}
}
+230
View File
@@ -0,0 +1,230 @@
{
"@@locale": "en",
"appTitle": "Focus Seed",
"brandName": "Focus Seed",
"saving": "Saving",
"local": "Local",
"navToday": "Today",
"navJourney": "Journey",
"navMe": "Me",
"tryAgain": "Try again",
"couldNotOpenApp": "Could not open Focus Seed.",
"openFailed": "Your local garden could not be opened.",
"saveFailed": "That change could not be saved. Please try again.",
"goodMorning": "Good morning",
"goodDay": "Good day",
"goodEvening": "Good evening",
"goodNight": "Good night",
"readableDate": "{date} - {weekday}",
"@readableDate": {
"placeholders": {
"date": {
"type": "Object"
},
"weekday": {
"type": "Object"
}
}
},
"todayIntentionLabel": "TODAY'S INTENTION",
"chooseOneOutcome": "Choose one outcome",
"focusLabel": "FOCUS",
"timerReady": "Ready when you are",
"timerRunning": "Deep work in progress",
"timerPaused": "Paused - your place is kept",
"timerCompleted": "Session complete",
"reflectOneMinute": "Reflect for 1 min",
"startAnotherSession": "Start another session",
"setTodaysIntention": "Set today's intention",
"pause": "Pause",
"end": "End",
"resume": "Resume",
"startMinutes": "Start {minutes} min",
"@startMinutes": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"resetTimer": "Reset timer",
"todaysHabits": "Today's habits",
"completedCount": "{completed}/{total} complete",
"@completedCount": {
"placeholders": {
"completed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"manageHabits": "Manage habits",
"addHabit": "Add habit",
"add": "Add",
"emptyHabitPrompt": "Add one repeatable habit.",
"viewAllHabits": "View all {count}",
"@viewAllHabits": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"reflectionSaved": "Reflection saved",
"reflectionPromptTitle": "How did it go?",
"reflectionPromptBody": "Write one useful sentence.",
"progressIntention": "Intention",
"progressFocus": "Focus",
"progressHabits": "Habits",
"progressReflect": "Reflect",
"done": "Done",
"next": "Next",
"later": "Later",
"minutesShort": "{minutes} min",
"@minutesShort": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"dailyStepsComplete": "{completed}/4 daily steps complete",
"@dailyStepsComplete": {
"placeholders": {
"completed": {
"type": "int"
}
}
},
"habitOptions": "Habit options",
"rename": "Rename",
"delete": "Delete",
"habitsTitle": "Habits",
"habitsSubtitle": "Keep it small and repeatable.",
"emptyHabitsTitle": "Add a small habit",
"addHabitTitle": "Add habit",
"renameHabitTitle": "Rename habit",
"habitHint": "Example: Read 10 pages",
"cancel": "Cancel",
"save": "Save",
"setIntentionTitle": "Set intention",
"editIntentionTitle": "Edit intention",
"intentionHint": "What matters most today?",
"deleteHabitTitle": "Delete habit?",
"deleteHabitBody": "Past completion records for this habit will be removed.",
"exportJson": "Export JSON",
"close": "Close",
"copy": "Copy",
"exportCopied": "Export copied",
"importJson": "Import JSON",
"importHint": "Paste Focus Seed JSON export",
"import": "Import",
"clearDataTitle": "Clear local data?",
"clearDataBody": "This resets habits, focus sessions, and reflections on this device.",
"clear": "Clear",
"privacyTitle": "Privacy",
"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 export, import, or clear local data from Me.",
"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.",
"savingLabel": "Saving",
"saveReflection": "Save reflection",
"moodCalm": "Calm",
"moodClear": "Clear",
"moodProud": "Proud",
"moodTired": "Tired",
"meTitle": "Your space",
"meSubtitle": "Private by default. No account required.",
"appearance": "Appearance",
"system": "System",
"light": "Light",
"dark": "Dark",
"language": "Language",
"developmentOnly": "Development only",
"languageDefault": "Default",
"languageEnglish": "English",
"languageChinese": "Chinese",
"yourData": "Your data",
"exportJsonSubtitle": "Copy a readable local backup.",
"importJsonSubtitle": "Restore a previous Focus Seed export.",
"privacySubtitle": "Everything stays on this device.",
"resetLocalGarden": "Reset local garden",
"resetLocalGardenSubtitle": "Clear intentions, habits, sessions, and notes.",
"dataImported": "Data imported",
"importFailed": "Import failed",
"localDataCleared": "Local data cleared",
"journeyTitle": "Today's journey",
"recentFocus": "Recent focus",
"sessionsTotal": "{count} sessions total",
"@sessionsTotal": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"focusMinutes": "{minutes} min focus",
"@focusMinutes": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"recentReflections": "Recent reflections",
"recentReflectionsDetail": "Lessons worth keeping",
"focusedMinutes": "{minutes} focused minutes",
"@focusedMinutes": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"noFocusSessionYet": "No session completed yet",
"habitsCompleted": "{completed}/{total} completed",
"@habitsCompleted": {
"placeholders": {
"completed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"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": {
"placeholders": {
"date": {
"type": "Object"
},
"stage": {
"type": "Object"
}
}
},
"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",
"defaultHabitPlanTask": "Plan one clear task",
"defaultHabitFocus": "Focus for 25 minutes",
"defaultHabitReflect": "Write one-line reflection"
}
+230
View File
@@ -0,0 +1,230 @@
{
"@@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",
"readableDate": "{date} - {weekday}",
"@readableDate": {
"placeholders": {
"date": {
"type": "Object"
},
"weekday": {
"type": "Object"
}
}
},
"todayIntentionLabel": "\u4eca\u65e5\u76ee\u6807",
"chooseOneOutcome": "\u9009\u4e00\u4e2a\u7ed3\u679c",
"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\u76ee\u6807",
"pause": "\u6682\u505c",
"end": "\u7ed3\u675f",
"resume": "\u7ee7\u7eed",
"startMinutes": "\u5f00\u59cb {minutes} \u5206\u949f",
"@startMinutes": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"resetTimer": "\u91cd\u7f6e\u8ba1\u65f6",
"todaysHabits": "\u4eca\u65e5\u4e60\u60ef",
"completedCount": "{completed}/{total} \u5b8c\u6210",
"@completedCount": {
"placeholders": {
"completed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"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",
"@viewAllHabits": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"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",
"@minutesShort": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"dailyStepsComplete": "\u4eca\u65e5\u5b8c\u6210 {completed}/4",
"@dailyStepsComplete": {
"placeholders": {
"completed": {
"type": "int"
}
}
},
"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",
"exportJson": "\u5bfc\u51fa JSON",
"close": "\u5173\u95ed",
"copy": "\u590d\u5236",
"exportCopied": "\u5df2\u590d\u5236\u5bfc\u51fa\u5185\u5bb9",
"importJson": "\u5bfc\u5165 JSON",
"importHint": "\u7c98\u8d34 Focus Seed \u7684 JSON \u5bfc\u51fa\u5185\u5bb9",
"import": "\u5bfc\u5165",
"clearDataTitle": "\u6e05\u9664\u672c\u5730\u6570\u636e\uff1f",
"clearDataBody": "\u4f1a\u6e05\u9664\u672c\u673a\u7684\u76ee\u6807\u3001\u4e60\u60ef\u3001\u4e13\u6ce8\u8bb0\u5f55\u548c\u590d\u76d8\u3002",
"clear": "\u6e05\u9664",
"privacyTitle": "\u9690\u79c1",
"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\u5728\u201c\u6211\u7684\u7a7a\u95f4\u201d\u5bfc\u51fa\u3001\u5bfc\u5165\u6216\u6e05\u9664\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\u7a7a\u95f4",
"meSubtitle": "\u672c\u5730\u79c1\u6709\uff0c\u65e0\u9700\u8d26\u53f7\u3002",
"appearance": "\u5916\u89c2",
"system": "\u7cfb\u7edf",
"light": "\u6d45\u8272",
"dark": "\u6df1\u8272",
"language": "\u8bed\u8a00",
"developmentOnly": "\u4ec5\u5f00\u53d1\u73af\u5883",
"languageDefault": "\u9ed8\u8ba4",
"languageEnglish": "English",
"languageChinese": "\u4e2d\u6587",
"yourData": "\u6570\u636e",
"exportJsonSubtitle": "\u590d\u5236\u4e00\u4efd\u672c\u5730\u5907\u4efd\u3002",
"importJsonSubtitle": "\u6062\u590d\u4e4b\u524d\u5bfc\u51fa\u7684\u6570\u636e\u3002",
"privacySubtitle": "\u6570\u636e\u53ea\u4fdd\u5b58\u5728\u672c\u673a\u3002",
"resetLocalGarden": "\u6e05\u9664\u672c\u5730\u6570\u636e",
"resetLocalGardenSubtitle": "\u6e05\u9664\u76ee\u6807\u3001\u4e60\u60ef\u3001\u8bb0\u5f55\u548c\u590d\u76d8\u3002",
"dataImported": "\u5df2\u5bfc\u5165\u6570\u636e",
"importFailed": "\u5bfc\u5165\u5931\u8d25",
"localDataCleared": "\u672c\u5730\u6570\u636e\u5df2\u6e05\u9664",
"journeyTitle": "\u4eca\u65e5\u8fdb\u5c55",
"recentFocus": "\u6700\u8fd1\u4e13\u6ce8",
"sessionsTotal": "\u5171 {count} \u6b21",
"@sessionsTotal": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"focusMinutes": "\u4e13\u6ce8 {minutes} \u5206\u949f",
"@focusMinutes": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"recentReflections": "\u6700\u8fd1\u590d\u76d8",
"recentReflectionsDetail": "\u503c\u5f97\u4fdd\u7559\u7684\u8bb0\u5f55",
"focusedMinutes": "\u5df2\u4e13\u6ce8 {minutes} \u5206\u949f",
"@focusedMinutes": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"noFocusSessionYet": "\u8fd8\u6ca1\u6709\u5b8c\u6210\u4e13\u6ce8",
"habitsCompleted": "{completed}/{total} \u5b8c\u6210",
"@habitsCompleted": {
"placeholders": {
"completed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"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": {
"placeholders": {
"date": {
"type": "Object"
},
"stage": {
"type": "Object"
}
}
},
"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"
}
@@ -0,0 +1,957 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'generated/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, you’ll need to edit this
/// file.
///
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// project’s Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
}
static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('zh')
];
/// No description provided for @appTitle.
///
/// In en, this message translates to:
/// **'Focus Seed'**
String get appTitle;
/// No description provided for @brandName.
///
/// In en, this message translates to:
/// **'Focus Seed'**
String get brandName;
/// No description provided for @saving.
///
/// In en, this message translates to:
/// **'Saving'**
String get saving;
/// No description provided for @local.
///
/// In en, this message translates to:
/// **'Local'**
String get local;
/// No description provided for @navToday.
///
/// In en, this message translates to:
/// **'Today'**
String get navToday;
/// No description provided for @navJourney.
///
/// In en, this message translates to:
/// **'Journey'**
String get navJourney;
/// No description provided for @navMe.
///
/// In en, this message translates to:
/// **'Me'**
String get navMe;
/// No description provided for @tryAgain.
///
/// In en, this message translates to:
/// **'Try again'**
String get tryAgain;
/// No description provided for @couldNotOpenApp.
///
/// In en, this message translates to:
/// **'Could not open Focus Seed.'**
String get couldNotOpenApp;
/// No description provided for @openFailed.
///
/// In en, this message translates to:
/// **'Your local garden could not be opened.'**
String get openFailed;
/// No description provided for @saveFailed.
///
/// In en, this message translates to:
/// **'That change could not be saved. Please try again.'**
String get saveFailed;
/// No description provided for @goodMorning.
///
/// In en, this message translates to:
/// **'Good morning'**
String get goodMorning;
/// No description provided for @goodDay.
///
/// In en, this message translates to:
/// **'Good day'**
String get goodDay;
/// No description provided for @goodEvening.
///
/// In en, this message translates to:
/// **'Good evening'**
String get goodEvening;
/// No description provided for @goodNight.
///
/// In en, this message translates to:
/// **'Good night'**
String get goodNight;
/// No description provided for @readableDate.
///
/// In en, this message translates to:
/// **'{date} - {weekday}'**
String readableDate(Object date, Object weekday);
/// No description provided for @todayIntentionLabel.
///
/// In en, this message translates to:
/// **'TODAY\'S INTENTION'**
String get todayIntentionLabel;
/// No description provided for @chooseOneOutcome.
///
/// In en, this message translates to:
/// **'Choose one outcome'**
String get chooseOneOutcome;
/// No description provided for @focusLabel.
///
/// In en, this message translates to:
/// **'FOCUS'**
String get focusLabel;
/// No description provided for @timerReady.
///
/// In en, this message translates to:
/// **'Ready when you are'**
String get timerReady;
/// No description provided for @timerRunning.
///
/// In en, this message translates to:
/// **'Deep work in progress'**
String get timerRunning;
/// No description provided for @timerPaused.
///
/// In en, this message translates to:
/// **'Paused - your place is kept'**
String get timerPaused;
/// No description provided for @timerCompleted.
///
/// In en, this message translates to:
/// **'Session complete'**
String get timerCompleted;
/// No description provided for @reflectOneMinute.
///
/// In en, this message translates to:
/// **'Reflect for 1 min'**
String get reflectOneMinute;
/// No description provided for @startAnotherSession.
///
/// In en, this message translates to:
/// **'Start another session'**
String get startAnotherSession;
/// No description provided for @setTodaysIntention.
///
/// In en, this message translates to:
/// **'Set today\'s intention'**
String get setTodaysIntention;
/// No description provided for @pause.
///
/// In en, this message translates to:
/// **'Pause'**
String get pause;
/// No description provided for @end.
///
/// In en, this message translates to:
/// **'End'**
String get end;
/// No description provided for @resume.
///
/// In en, this message translates to:
/// **'Resume'**
String get resume;
/// No description provided for @startMinutes.
///
/// In en, this message translates to:
/// **'Start {minutes} min'**
String startMinutes(int minutes);
/// No description provided for @resetTimer.
///
/// In en, this message translates to:
/// **'Reset timer'**
String get resetTimer;
/// No description provided for @todaysHabits.
///
/// In en, this message translates to:
/// **'Today\'s habits'**
String get todaysHabits;
/// No description provided for @completedCount.
///
/// In en, this message translates to:
/// **'{completed}/{total} complete'**
String completedCount(int completed, int total);
/// No description provided for @manageHabits.
///
/// In en, this message translates to:
/// **'Manage habits'**
String get manageHabits;
/// No description provided for @addHabit.
///
/// In en, this message translates to:
/// **'Add habit'**
String get addHabit;
/// No description provided for @add.
///
/// In en, this message translates to:
/// **'Add'**
String get add;
/// No description provided for @emptyHabitPrompt.
///
/// In en, this message translates to:
/// **'Add one repeatable habit.'**
String get emptyHabitPrompt;
/// No description provided for @viewAllHabits.
///
/// In en, this message translates to:
/// **'View all {count}'**
String viewAllHabits(int count);
/// No description provided for @reflectionSaved.
///
/// In en, this message translates to:
/// **'Reflection saved'**
String get reflectionSaved;
/// No description provided for @reflectionPromptTitle.
///
/// In en, this message translates to:
/// **'How did it go?'**
String get reflectionPromptTitle;
/// No description provided for @reflectionPromptBody.
///
/// In en, this message translates to:
/// **'Write one useful sentence.'**
String get reflectionPromptBody;
/// No description provided for @progressIntention.
///
/// In en, this message translates to:
/// **'Intention'**
String get progressIntention;
/// No description provided for @progressFocus.
///
/// In en, this message translates to:
/// **'Focus'**
String get progressFocus;
/// No description provided for @progressHabits.
///
/// In en, this message translates to:
/// **'Habits'**
String get progressHabits;
/// No description provided for @progressReflect.
///
/// In en, this message translates to:
/// **'Reflect'**
String get progressReflect;
/// No description provided for @done.
///
/// In en, this message translates to:
/// **'Done'**
String get done;
/// No description provided for @next.
///
/// In en, this message translates to:
/// **'Next'**
String get next;
/// No description provided for @later.
///
/// In en, this message translates to:
/// **'Later'**
String get later;
/// No description provided for @minutesShort.
///
/// In en, this message translates to:
/// **'{minutes} min'**
String minutesShort(int minutes);
/// No description provided for @dailyStepsComplete.
///
/// In en, this message translates to:
/// **'{completed}/4 daily steps complete'**
String dailyStepsComplete(int completed);
/// No description provided for @habitOptions.
///
/// In en, this message translates to:
/// **'Habit options'**
String get habitOptions;
/// No description provided for @rename.
///
/// In en, this message translates to:
/// **'Rename'**
String get rename;
/// No description provided for @delete.
///
/// In en, this message translates to:
/// **'Delete'**
String get delete;
/// No description provided for @habitsTitle.
///
/// In en, this message translates to:
/// **'Habits'**
String get habitsTitle;
/// No description provided for @habitsSubtitle.
///
/// In en, this message translates to:
/// **'Keep it small and repeatable.'**
String get habitsSubtitle;
/// No description provided for @emptyHabitsTitle.
///
/// In en, this message translates to:
/// **'Add a small habit'**
String get emptyHabitsTitle;
/// No description provided for @addHabitTitle.
///
/// In en, this message translates to:
/// **'Add habit'**
String get addHabitTitle;
/// No description provided for @renameHabitTitle.
///
/// In en, this message translates to:
/// **'Rename habit'**
String get renameHabitTitle;
/// No description provided for @habitHint.
///
/// In en, this message translates to:
/// **'Example: Read 10 pages'**
String get habitHint;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancel;
/// No description provided for @save.
///
/// In en, this message translates to:
/// **'Save'**
String get save;
/// No description provided for @setIntentionTitle.
///
/// In en, this message translates to:
/// **'Set intention'**
String get setIntentionTitle;
/// No description provided for @editIntentionTitle.
///
/// In en, this message translates to:
/// **'Edit intention'**
String get editIntentionTitle;
/// No description provided for @intentionHint.
///
/// In en, this message translates to:
/// **'What matters most today?'**
String get intentionHint;
/// No description provided for @deleteHabitTitle.
///
/// In en, this message translates to:
/// **'Delete habit?'**
String get deleteHabitTitle;
/// No description provided for @deleteHabitBody.
///
/// In en, this message translates to:
/// **'Past completion records for this habit will be removed.'**
String get deleteHabitBody;
/// No description provided for @exportJson.
///
/// In en, this message translates to:
/// **'Export JSON'**
String get exportJson;
/// No description provided for @close.
///
/// In en, this message translates to:
/// **'Close'**
String get close;
/// No description provided for @copy.
///
/// In en, this message translates to:
/// **'Copy'**
String get copy;
/// No description provided for @exportCopied.
///
/// In en, this message translates to:
/// **'Export copied'**
String get exportCopied;
/// No description provided for @importJson.
///
/// In en, this message translates to:
/// **'Import JSON'**
String get importJson;
/// No description provided for @importHint.
///
/// In en, this message translates to:
/// **'Paste Focus Seed JSON export'**
String get importHint;
/// No description provided for @import.
///
/// In en, this message translates to:
/// **'Import'**
String get import;
/// No description provided for @clearDataTitle.
///
/// In en, this message translates to:
/// **'Clear local data?'**
String get clearDataTitle;
/// No description provided for @clearDataBody.
///
/// In en, this message translates to:
/// **'This resets habits, focus sessions, and reflections on this device.'**
String get clearDataBody;
/// No description provided for @clear.
///
/// In en, this message translates to:
/// **'Clear'**
String get clear;
/// No description provided for @privacyTitle.
///
/// In en, this message translates to:
/// **'Privacy'**
String get privacyTitle;
/// No description provided for @privacyBodyOne.
///
/// In en, this message translates to:
/// **'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.'**
String get privacyBodyOne;
/// No description provided for @privacyBodyTwo.
///
/// In en, this message translates to:
/// **'You can export, import, or clear local data from Me.'**
String get privacyBodyTwo;
/// No description provided for @reflectionEmptyError.
///
/// In en, this message translates to:
/// **'Write one honest sentence.'**
String get reflectionEmptyError;
/// No description provided for @reflectionSaveError.
///
/// In en, this message translates to:
/// **'Could not save. Please try again.'**
String get reflectionSaveError;
/// No description provided for @reflectionSheetTitle.
///
/// In en, this message translates to:
/// **'How did it go?'**
String get reflectionSheetTitle;
/// No description provided for @reflectionSheetSubtitle.
///
/// In en, this message translates to:
/// **'Keep what worked. Adjust one thing.'**
String get reflectionSheetSubtitle;
/// No description provided for @reflectionHint.
///
/// In en, this message translates to:
/// **'One useful sentence is enough.'**
String get reflectionHint;
/// No description provided for @savingLabel.
///
/// In en, this message translates to:
/// **'Saving'**
String get savingLabel;
/// No description provided for @saveReflection.
///
/// In en, this message translates to:
/// **'Save reflection'**
String get saveReflection;
/// No description provided for @moodCalm.
///
/// In en, this message translates to:
/// **'Calm'**
String get moodCalm;
/// No description provided for @moodClear.
///
/// In en, this message translates to:
/// **'Clear'**
String get moodClear;
/// No description provided for @moodProud.
///
/// In en, this message translates to:
/// **'Proud'**
String get moodProud;
/// No description provided for @moodTired.
///
/// In en, this message translates to:
/// **'Tired'**
String get moodTired;
/// No description provided for @meTitle.
///
/// In en, this message translates to:
/// **'Your space'**
String get meTitle;
/// No description provided for @meSubtitle.
///
/// In en, this message translates to:
/// **'Private by default. No account required.'**
String get meSubtitle;
/// No description provided for @appearance.
///
/// In en, this message translates to:
/// **'Appearance'**
String get appearance;
/// No description provided for @system.
///
/// In en, this message translates to:
/// **'System'**
String get system;
/// No description provided for @light.
///
/// In en, this message translates to:
/// **'Light'**
String get light;
/// No description provided for @dark.
///
/// In en, this message translates to:
/// **'Dark'**
String get dark;
/// No description provided for @language.
///
/// In en, this message translates to:
/// **'Language'**
String get language;
/// No description provided for @developmentOnly.
///
/// In en, this message translates to:
/// **'Development only'**
String get developmentOnly;
/// No description provided for @languageDefault.
///
/// In en, this message translates to:
/// **'Default'**
String get languageDefault;
/// No description provided for @languageEnglish.
///
/// In en, this message translates to:
/// **'English'**
String get languageEnglish;
/// No description provided for @languageChinese.
///
/// In en, this message translates to:
/// **'Chinese'**
String get languageChinese;
/// No description provided for @yourData.
///
/// In en, this message translates to:
/// **'Your data'**
String get yourData;
/// No description provided for @exportJsonSubtitle.
///
/// In en, this message translates to:
/// **'Copy a readable local backup.'**
String get exportJsonSubtitle;
/// No description provided for @importJsonSubtitle.
///
/// In en, this message translates to:
/// **'Restore a previous Focus Seed export.'**
String get importJsonSubtitle;
/// No description provided for @privacySubtitle.
///
/// In en, this message translates to:
/// **'Everything stays on this device.'**
String get privacySubtitle;
/// No description provided for @resetLocalGarden.
///
/// In en, this message translates to:
/// **'Reset local garden'**
String get resetLocalGarden;
/// No description provided for @resetLocalGardenSubtitle.
///
/// In en, this message translates to:
/// **'Clear intentions, habits, sessions, and notes.'**
String get resetLocalGardenSubtitle;
/// No description provided for @dataImported.
///
/// In en, this message translates to:
/// **'Data imported'**
String get dataImported;
/// No description provided for @importFailed.
///
/// In en, this message translates to:
/// **'Import failed'**
String get importFailed;
/// No description provided for @localDataCleared.
///
/// In en, this message translates to:
/// **'Local data cleared'**
String get localDataCleared;
/// No description provided for @journeyTitle.
///
/// In en, this message translates to:
/// **'Today\'s journey'**
String get journeyTitle;
/// No description provided for @recentFocus.
///
/// In en, this message translates to:
/// **'Recent focus'**
String get recentFocus;
/// No description provided for @sessionsTotal.
///
/// In en, this message translates to:
/// **'{count} sessions total'**
String sessionsTotal(int count);
/// No description provided for @focusMinutes.
///
/// In en, this message translates to:
/// **'{minutes} min focus'**
String focusMinutes(int minutes);
/// No description provided for @recentReflections.
///
/// In en, this message translates to:
/// **'Recent reflections'**
String get recentReflections;
/// No description provided for @recentReflectionsDetail.
///
/// In en, this message translates to:
/// **'Lessons worth keeping'**
String get recentReflectionsDetail;
/// No description provided for @focusedMinutes.
///
/// In en, this message translates to:
/// **'{minutes} focused minutes'**
String focusedMinutes(int minutes);
/// No description provided for @noFocusSessionYet.
///
/// In en, this message translates to:
/// **'No session completed yet'**
String get noFocusSessionYet;
/// No description provided for @habitsCompleted.
///
/// In en, this message translates to:
/// **'{completed}/{total} completed'**
String habitsCompleted(int completed, int total);
/// No description provided for @reflectionCaptured.
///
/// In en, this message translates to:
/// **'Today\'s lesson is captured'**
String get reflectionCaptured;
/// No description provided for @reflectionAction.
///
/// In en, this message translates to:
/// **'Close the loop with one sentence'**
String get reflectionAction;
/// No description provided for @growthTitle.
///
/// In en, this message translates to:
/// **'Growth'**
String get growthTitle;
/// No description provided for @growthFlowerOpen.
///
/// In en, this message translates to:
/// **'Today\'s flower is open'**
String get growthFlowerOpen;
/// No description provided for @growthLocked.
///
/// In en, this message translates to:
/// **'Unlocked after reflection'**
String get growthLocked;
/// No description provided for @sevenDayGrowth.
///
/// In en, this message translates to:
/// **'7-day growth'**
String get sevenDayGrowth;
/// No description provided for @sevenDayGrowthSubtitle.
///
/// In en, this message translates to:
/// **'Small actions become visible over time.'**
String get sevenDayGrowthSubtitle;
/// No description provided for @growthSeed.
///
/// In en, this message translates to:
/// **'seed'**
String get growthSeed;
/// No description provided for @growthSprout.
///
/// In en, this message translates to:
/// **'sprout'**
String get growthSprout;
/// No description provided for @growthLeaves.
///
/// In en, this message translates to:
/// **'leaves'**
String get growthLeaves;
/// No description provided for @growthFlower.
///
/// In en, this message translates to:
/// **'flower'**
String get growthFlower;
/// No description provided for @growthDaySemantic.
///
/// In en, this message translates to:
/// **'{date}, {stage}'**
String growthDaySemantic(Object date, Object stage);
/// No description provided for @growthSeedSemantic.
///
/// In en, this message translates to:
/// **'A seed waiting for today\'s intention'**
String get growthSeedSemantic;
/// No description provided for @growthSproutSemantic.
///
/// In en, this message translates to:
/// **'A sprout growing from today\'s intention'**
String get growthSproutSemantic;
/// No description provided for @growthLeavesSemantic.
///
/// In en, this message translates to:
/// **'A young plant growing with today\'s progress'**
String get growthLeavesSemantic;
/// No description provided for @growthFlowerSemantic.
///
/// In en, this message translates to:
/// **'A flower marking a complete daily cycle'**
String get growthFlowerSemantic;
/// No description provided for @defaultHabitPlanTask.
///
/// In en, this message translates to:
/// **'Plan one clear task'**
String get defaultHabitPlanTask;
/// No description provided for @defaultHabitFocus.
///
/// In en, this message translates to:
/// **'Focus for 25 minutes'**
String get defaultHabitFocus;
/// No description provided for @defaultHabitReflect.
///
/// In en, this message translates to:
/// **'Write one-line reflection'**
String get defaultHabitReflect;
}
class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) => <String>['en', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en': return AppLocalizationsEn();
case 'zh': return AppLocalizationsZh();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.'
);
}
@@ -0,0 +1,446 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'Focus Seed';
@override
String get brandName => 'Focus Seed';
@override
String get saving => 'Saving';
@override
String get local => 'Local';
@override
String get navToday => 'Today';
@override
String get navJourney => 'Journey';
@override
String get navMe => 'Me';
@override
String get tryAgain => 'Try again';
@override
String get couldNotOpenApp => 'Could not open Focus Seed.';
@override
String get openFailed => 'Your local garden could not be opened.';
@override
String get saveFailed => 'That change could not be saved. Please try again.';
@override
String get goodMorning => 'Good morning';
@override
String get goodDay => 'Good day';
@override
String get goodEvening => 'Good evening';
@override
String get goodNight => 'Good night';
@override
String readableDate(Object date, Object weekday) {
return '$date - $weekday';
}
@override
String get todayIntentionLabel => 'TODAY\'S INTENTION';
@override
String get chooseOneOutcome => 'Choose one outcome';
@override
String get focusLabel => 'FOCUS';
@override
String get timerReady => 'Ready when you are';
@override
String get timerRunning => 'Deep work in progress';
@override
String get timerPaused => 'Paused - your place is kept';
@override
String get timerCompleted => 'Session complete';
@override
String get reflectOneMinute => 'Reflect for 1 min';
@override
String get startAnotherSession => 'Start another session';
@override
String get setTodaysIntention => 'Set today\'s intention';
@override
String get pause => 'Pause';
@override
String get end => 'End';
@override
String get resume => 'Resume';
@override
String startMinutes(int minutes) {
return 'Start $minutes min';
}
@override
String get resetTimer => 'Reset timer';
@override
String get todaysHabits => 'Today\'s habits';
@override
String completedCount(int completed, int total) {
return '$completed/$total complete';
}
@override
String get manageHabits => 'Manage habits';
@override
String get addHabit => 'Add habit';
@override
String get add => 'Add';
@override
String get emptyHabitPrompt => 'Add one repeatable habit.';
@override
String viewAllHabits(int count) {
return 'View all $count';
}
@override
String get reflectionSaved => 'Reflection saved';
@override
String get reflectionPromptTitle => 'How did it go?';
@override
String get reflectionPromptBody => 'Write one useful sentence.';
@override
String get progressIntention => 'Intention';
@override
String get progressFocus => 'Focus';
@override
String get progressHabits => 'Habits';
@override
String get progressReflect => 'Reflect';
@override
String get done => 'Done';
@override
String get next => 'Next';
@override
String get later => 'Later';
@override
String minutesShort(int minutes) {
return '$minutes min';
}
@override
String dailyStepsComplete(int completed) {
return '$completed/4 daily steps complete';
}
@override
String get habitOptions => 'Habit options';
@override
String get rename => 'Rename';
@override
String get delete => 'Delete';
@override
String get habitsTitle => 'Habits';
@override
String get habitsSubtitle => 'Keep it small and repeatable.';
@override
String get emptyHabitsTitle => 'Add a small habit';
@override
String get addHabitTitle => 'Add habit';
@override
String get renameHabitTitle => 'Rename habit';
@override
String get habitHint => 'Example: Read 10 pages';
@override
String get cancel => 'Cancel';
@override
String get save => 'Save';
@override
String get setIntentionTitle => 'Set intention';
@override
String get editIntentionTitle => 'Edit intention';
@override
String get intentionHint => 'What matters most today?';
@override
String get deleteHabitTitle => 'Delete habit?';
@override
String get deleteHabitBody => 'Past completion records for this habit will be removed.';
@override
String get exportJson => 'Export JSON';
@override
String get close => 'Close';
@override
String get copy => 'Copy';
@override
String get exportCopied => 'Export copied';
@override
String get importJson => 'Import JSON';
@override
String get importHint => 'Paste Focus Seed JSON export';
@override
String get import => 'Import';
@override
String get clearDataTitle => 'Clear local data?';
@override
String get clearDataBody => 'This resets habits, focus sessions, and reflections on this device.';
@override
String get clear => 'Clear';
@override
String get privacyTitle => 'Privacy';
@override
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 export, import, or clear local data from Me.';
@override
String get reflectionEmptyError => 'Write one honest sentence.';
@override
String get reflectionSaveError => 'Could not save. Please try again.';
@override
String get reflectionSheetTitle => 'How did it go?';
@override
String get reflectionSheetSubtitle => 'Keep what worked. Adjust one thing.';
@override
String get reflectionHint => 'One useful sentence is enough.';
@override
String get savingLabel => 'Saving';
@override
String get saveReflection => 'Save reflection';
@override
String get moodCalm => 'Calm';
@override
String get moodClear => 'Clear';
@override
String get moodProud => 'Proud';
@override
String get moodTired => 'Tired';
@override
String get meTitle => 'Your space';
@override
String get meSubtitle => 'Private by default. No account required.';
@override
String get appearance => 'Appearance';
@override
String get system => 'System';
@override
String get light => 'Light';
@override
String get dark => 'Dark';
@override
String get language => 'Language';
@override
String get developmentOnly => 'Development only';
@override
String get languageDefault => 'Default';
@override
String get languageEnglish => 'English';
@override
String get languageChinese => 'Chinese';
@override
String get yourData => 'Your data';
@override
String get exportJsonSubtitle => 'Copy a readable local backup.';
@override
String get importJsonSubtitle => 'Restore a previous Focus Seed export.';
@override
String get privacySubtitle => 'Everything stays on this device.';
@override
String get resetLocalGarden => 'Reset local garden';
@override
String get resetLocalGardenSubtitle => 'Clear intentions, habits, sessions, and notes.';
@override
String get dataImported => 'Data imported';
@override
String get importFailed => 'Import failed';
@override
String get localDataCleared => 'Local data cleared';
@override
String get journeyTitle => 'Today\'s journey';
@override
String get recentFocus => 'Recent focus';
@override
String sessionsTotal(int count) {
return '$count sessions total';
}
@override
String focusMinutes(int minutes) {
return '$minutes min focus';
}
@override
String get recentReflections => 'Recent reflections';
@override
String get recentReflectionsDetail => 'Lessons worth keeping';
@override
String focusedMinutes(int minutes) {
return '$minutes focused minutes';
}
@override
String get noFocusSessionYet => 'No session completed yet';
@override
String habitsCompleted(int completed, int total) {
return '$completed/$total completed';
}
@override
String get reflectionCaptured => 'Today\'s lesson is captured';
@override
String get reflectionAction => 'Close the loop with one sentence';
@override
String get growthTitle => 'Growth';
@override
String get growthFlowerOpen => 'Today\'s flower is open';
@override
String get growthLocked => 'Unlocked after reflection';
@override
String get sevenDayGrowth => '7-day growth';
@override
String get sevenDayGrowthSubtitle => 'Small actions become visible over time.';
@override
String get growthSeed => 'seed';
@override
String get growthSprout => 'sprout';
@override
String get growthLeaves => 'leaves';
@override
String get growthFlower => 'flower';
@override
String growthDaySemantic(Object date, Object stage) {
return '$date, $stage';
}
@override
String get growthSeedSemantic => 'A seed waiting for today\'s intention';
@override
String get growthSproutSemantic => 'A sprout growing from today\'s intention';
@override
String get growthLeavesSemantic => 'A young plant growing with today\'s progress';
@override
String get growthFlowerSemantic => 'A flower marking a complete daily cycle';
@override
String get defaultHabitPlanTask => 'Plan one clear task';
@override
String get defaultHabitFocus => 'Focus for 25 minutes';
@override
String get defaultHabitReflect => 'Write one-line reflection';
}
@@ -0,0 +1,446 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appTitle => 'Focus Seed';
@override
String get brandName => 'Focus Seed';
@override
String get saving => '保存中';
@override
String get local => '本地';
@override
String get navToday => '今日';
@override
String get navJourney => '进展';
@override
String get navMe => '我的';
@override
String get tryAgain => '重试';
@override
String get couldNotOpenApp => '无法打开 Focus Seed。';
@override
String get openFailed => '本地数据打开失败。';
@override
String get saveFailed => '保存失败,请重试。';
@override
String get goodMorning => '早上好';
@override
String get goodDay => '今天好';
@override
String get goodEvening => '晚上好';
@override
String get goodNight => '夜深了';
@override
String readableDate(Object date, Object weekday) {
return '$date - $weekday';
}
@override
String get todayIntentionLabel => '今日目标';
@override
String get chooseOneOutcome => '选一个结果';
@override
String get focusLabel => '专注';
@override
String get timerReady => '准备好了就开始';
@override
String get timerRunning => '专注中';
@override
String get timerPaused => '已暂停';
@override
String get timerCompleted => '已完成';
@override
String get reflectOneMinute => '复盘 1 分钟';
@override
String get startAnotherSession => '再来一轮';
@override
String get setTodaysIntention => '设置今日目标';
@override
String get pause => '暂停';
@override
String get end => '结束';
@override
String get resume => '继续';
@override
String startMinutes(int minutes) {
return '开始 $minutes 分钟';
}
@override
String get resetTimer => '重置计时';
@override
String get todaysHabits => '今日习惯';
@override
String completedCount(int completed, int total) {
return '$completed/$total 完成';
}
@override
String get manageHabits => '管理习惯';
@override
String get addHabit => '添加习惯';
@override
String get add => '添加';
@override
String get emptyHabitPrompt => '添加一个可重复的小习惯。';
@override
String viewAllHabits(int count) {
return '查看全部 $count 个';
}
@override
String get reflectionSaved => '复盘已保存';
@override
String get reflectionPromptTitle => '复盘一下';
@override
String get reflectionPromptBody => '写一句有用的总结。';
@override
String get progressIntention => '目标';
@override
String get progressFocus => '专注';
@override
String get progressHabits => '习惯';
@override
String get progressReflect => '复盘';
@override
String get done => '完成';
@override
String get next => '下一步';
@override
String get later => '稍后';
@override
String minutesShort(int minutes) {
return '$minutes 分钟';
}
@override
String dailyStepsComplete(int completed) {
return '今日完成 $completed/4';
}
@override
String get habitOptions => '习惯选项';
@override
String get rename => '重命名';
@override
String get delete => '删除';
@override
String get habitsTitle => '习惯';
@override
String get habitsSubtitle => '少量、可重复。';
@override
String get emptyHabitsTitle => '添加小习惯';
@override
String get addHabitTitle => '添加习惯';
@override
String get renameHabitTitle => '重命名习惯';
@override
String get habitHint => '例如:读 10 页书';
@override
String get cancel => '取消';
@override
String get save => '保存';
@override
String get setIntentionTitle => '设置目标';
@override
String get editIntentionTitle => '编辑目标';
@override
String get intentionHint => '今天最重要的是什么?';
@override
String get deleteHabitTitle => '删除习惯?';
@override
String get deleteHabitBody => '这个习惯的历史完成记录也会删除。';
@override
String get exportJson => '导出 JSON';
@override
String get close => '关闭';
@override
String get copy => '复制';
@override
String get exportCopied => '已复制导出内容';
@override
String get importJson => '导入 JSON';
@override
String get importHint => '粘贴 Focus Seed 的 JSON 导出内容';
@override
String get import => '导入';
@override
String get clearDataTitle => '清除本地数据?';
@override
String get clearDataBody => '会清除本机的目标、习惯、专注记录和复盘。';
@override
String get clear => '清除';
@override
String get privacyTitle => '隐私';
@override
String get privacyBodyOne => 'Focus Seed 只在本机保存目标、习惯、专注记录、复盘和设置。当前版本没有登录、云同步、广告、分析 SDK 或付费 SDK。';
@override
String get privacyBodyTwo => '你可以在“我的空间”导出、导入或清除本地数据。';
@override
String get reflectionEmptyError => '写一句真实的总结。';
@override
String get reflectionSaveError => '保存失败,请重试。';
@override
String get reflectionSheetTitle => '复盘一下';
@override
String get reflectionSheetSubtitle => '保留有效的,调整一个点。';
@override
String get reflectionHint => '一句有用的话就够。';
@override
String get savingLabel => '保存中';
@override
String get saveReflection => '保存复盘';
@override
String get moodCalm => '平静';
@override
String get moodClear => '清醒';
@override
String get moodProud => '满意';
@override
String get moodTired => '疲惫';
@override
String get meTitle => '我的空间';
@override
String get meSubtitle => '本地私有,无需账号。';
@override
String get appearance => '外观';
@override
String get system => '系统';
@override
String get light => '浅色';
@override
String get dark => '深色';
@override
String get language => '语言';
@override
String get developmentOnly => '仅开发环境';
@override
String get languageDefault => '默认';
@override
String get languageEnglish => 'English';
@override
String get languageChinese => '中文';
@override
String get yourData => '数据';
@override
String get exportJsonSubtitle => '复制一份本地备份。';
@override
String get importJsonSubtitle => '恢复之前导出的数据。';
@override
String get privacySubtitle => '数据只保存在本机。';
@override
String get resetLocalGarden => '清除本地数据';
@override
String get resetLocalGardenSubtitle => '清除目标、习惯、记录和复盘。';
@override
String get dataImported => '已导入数据';
@override
String get importFailed => '导入失败';
@override
String get localDataCleared => '本地数据已清除';
@override
String get journeyTitle => '今日进展';
@override
String get recentFocus => '最近专注';
@override
String sessionsTotal(int count) {
return '共 $count 次';
}
@override
String focusMinutes(int minutes) {
return '专注 $minutes 分钟';
}
@override
String get recentReflections => '最近复盘';
@override
String get recentReflectionsDetail => '值得保留的记录';
@override
String focusedMinutes(int minutes) {
return '已专注 $minutes 分钟';
}
@override
String get noFocusSessionYet => '还没有完成专注';
@override
String habitsCompleted(int completed, int total) {
return '$completed/$total 完成';
}
@override
String get reflectionCaptured => '今日复盘已记录';
@override
String get reflectionAction => '用一句话收尾';
@override
String get growthTitle => '成长';
@override
String get growthFlowerOpen => '今日花朵已开放';
@override
String get growthLocked => '复盘后解锁';
@override
String get sevenDayGrowth => '7 日成长';
@override
String get sevenDayGrowthSubtitle => '连续行动会被看见。';
@override
String get growthSeed => '种子';
@override
String get growthSprout => '发芽';
@override
String get growthLeaves => '生长';
@override
String get growthFlower => '开花';
@override
String growthDaySemantic(Object date, Object stage) {
return '$date,$stage';
}
@override
String get growthSeedSemantic => '等待今日目标的种子';
@override
String get growthSproutSemantic => '目标已种下,正在发芽';
@override
String get growthLeavesSemantic => '今日进度让植物生长';
@override
String get growthFlowerSemantic => '今日闭环完成,花朵开放';
@override
String get defaultHabitPlanTask => '计划一个明确任务';
@override
String get defaultHabitFocus => '专注 25 分钟';
@override
String get defaultHabitReflect => '写一句复盘';
}
+73
View File
@@ -0,0 +1,73 @@
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';
export 'generated/app_localizations.dart';
extension L10nContext on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this);
}
String localizedGreeting(AppLocalizations l10n, DateTime dateTime) {
return switch (dayPhaseFor(dateTime)) {
DayPhase.dawn => l10n.goodMorning,
DayPhase.day => l10n.goodDay,
DayPhase.dusk => l10n.goodEvening,
DayPhase.night => l10n.goodNight,
};
}
String localizedReadableDate(BuildContext context, DateTime dateTime) {
final localeTag = Localizations.localeOf(context).toLanguageTag();
return context.l10n.readableDate(
DateFormat.yMMMd(localeTag).format(dateTime),
DateFormat.E(localeTag).format(dateTime),
);
}
String localizedTime(BuildContext context, DateTime dateTime) {
final media = MediaQuery.maybeOf(context);
return MaterialLocalizations.of(context).formatTimeOfDay(
TimeOfDay.fromDateTime(dateTime),
alwaysUse24HourFormat: media?.alwaysUse24HourFormat ?? false,
);
}
String localizedMood(AppLocalizations l10n, Mood mood) {
return switch (mood) {
Mood.calm => l10n.moodCalm,
Mood.clear => l10n.moodClear,
Mood.proud => l10n.moodProud,
Mood.tired => l10n.moodTired,
};
}
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,
l10n.defaultHabitFocus,
l10n.defaultHabitReflect,
];
}
+35 -6
View File
@@ -5,26 +5,34 @@ import 'reflection_entry.dart';
class AppState {
const AppState({
required this.anchor,
required this.anchorDateKey,
required this.habits,
required this.sessions,
required this.reflections,
});
final String anchor;
final String anchorDateKey;
final List<Habit> habits;
final List<FocusSession> sessions;
final List<ReflectionEntry> reflections;
bool get isFreshStart => habits.isEmpty && sessions.isEmpty && anchor.isEmpty;
String activeAnchor(String dateKey) {
return anchorDateKey == dateKey ? anchor : '';
}
AppState copyWith({
String? anchor,
String? anchorDateKey,
List<Habit>? habits,
List<FocusSession>? sessions,
List<ReflectionEntry>? reflections,
}) {
return AppState(
anchor: anchor ?? this.anchor,
anchorDateKey: anchorDateKey ?? this.anchorDateKey,
habits: habits ?? this.habits,
sessions: sessions ?? this.sessions,
reflections: reflections ?? this.reflections,
@@ -33,8 +41,9 @@ class AppState {
Map<String, dynamic> toJson() {
return {
'version': 1,
'version': 2,
'anchor': anchor,
'anchorDateKey': anchorDateKey,
'habits': habits.map((habit) => habit.toJson()).toList(),
'sessions': sessions.map((session) => session.toJson()).toList(),
'reflections':
@@ -43,8 +52,12 @@ class AppState {
}
static AppState fromJson(Map<String, dynamic> json) {
final anchor = (json['anchor'] as String?) ?? '';
return AppState(
anchor: (json['anchor'] as String?) ?? '',
anchor: anchor,
anchorDateKey:
(json['anchorDateKey'] as String?) ??
(anchor.isEmpty ? '' : _currentDateKey()),
habits: _jsonObjects(json['habits']).map(Habit.fromJson).toList(),
sessions:
_jsonObjects(json['sessions']).map(FocusSession.fromJson).toList(),
@@ -62,26 +75,35 @@ class AppState {
return value.whereType<Map<String, dynamic>>();
}
static AppState starter() {
static AppState starter({List<String>? defaultHabitTitles}) {
final now = DateTime.now();
final titles =
defaultHabitTitles?.length == 3
? defaultHabitTitles!
: const [
'Plan one clear task',
'Focus for 25 minutes',
'Write one-line reflection',
];
return AppState(
anchor: '',
anchorDateKey: _currentDateKey(now),
habits: [
Habit(
id: 'habit-${now.microsecondsSinceEpoch}-1',
title: 'Plan one clear task',
title: titles[0],
createdAt: now,
completedDates: {},
),
Habit(
id: 'habit-${now.microsecondsSinceEpoch}-2',
title: 'Focus for 25 minutes',
title: titles[1],
createdAt: now,
completedDates: {},
),
Habit(
id: 'habit-${now.microsecondsSinceEpoch}-3',
title: 'Write a one-line reflection',
title: titles[2],
createdAt: now,
completedDates: {},
),
@@ -90,4 +112,11 @@ class AppState {
reflections: const [],
);
}
static String _currentDateKey([DateTime? value]) {
final date = value ?? DateTime.now();
return '${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,8 @@
enum AppearancePreference { system, light, dark }
AppearancePreference appearancePreferenceFromName(String? name) {
return AppearancePreference.values.firstWhere(
(preference) => preference.name == name,
orElse: () => AppearancePreference.system,
);
}
+29
View File
@@ -0,0 +1,29 @@
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',
};
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
enum LanguagePreference { defaultLocale, english, chinese }
LanguagePreference languagePreferenceFromName(String? name) {
return LanguagePreference.values.firstWhere(
(preference) => preference.name == name,
orElse: () => LanguagePreference.defaultLocale,
);
}
extension LanguagePreferenceLocale on LanguagePreference {
Locale? get locale => switch (this) {
LanguagePreference.defaultLocale => null,
LanguagePreference.english => const Locale('en'),
LanguagePreference.chinese => const Locale('zh'),
};
}
+5 -6
View File
@@ -1,13 +1,12 @@
import 'package:flutter/material.dart';
enum Mood {
calm('Calm', Icons.spa_outlined),
clear('Clear', Icons.wb_sunny_outlined),
proud('Proud', Icons.emoji_events_outlined),
tired('Tired', Icons.bedtime_outlined);
calm(Icons.spa_outlined),
clear(Icons.wb_sunny_outlined),
proud(Icons.emoji_events_outlined),
tired(Icons.bedtime_outlined);
const Mood(this.label, this.icon);
const Mood(this.icon);
final String label;
final IconData icon;
}
+49
View File
@@ -0,0 +1,49 @@
import '../core/date_keys.dart';
import 'app_state.dart';
import 'growth_stage.dart';
class TodayProgress {
const TodayProgress({
required this.dateKey,
required this.intention,
required this.focusMinutes,
required this.completedHabits,
required this.totalHabits,
required this.hasReflection,
required this.growthStage,
});
factory TodayProgress.fromState(AppState state, String dateKey) {
return TodayProgress(
dateKey: dateKey,
intention: state.activeAnchor(dateKey),
focusMinutes: minutesForDate(state.sessions, dateKey),
completedHabits:
state.habits
.where((habit) => habit.completedDates.contains(dateKey))
.length,
totalHabits: state.habits.length,
hasReflection: state.reflections.any((entry) => entry.dateKey == dateKey),
growthStage: growthStageFor(state, dateKey),
);
}
final String dateKey;
final String intention;
final int focusMinutes;
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;
int get completedSteps =>
[
hasIntention,
hasFocus,
hasHabits,
hasReflection,
].where((complete) => complete).length;
}
-52
View File
@@ -1,52 +0,0 @@
import 'package:flutter/material.dart';
class AppScaffold extends StatelessWidget {
const AppScaffold({
required this.title,
required this.subtitle,
required this.child,
this.action,
super.key,
});
final String title;
final String subtitle;
final Widget child;
final Widget? action;
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
if (action != null) action!,
],
),
),
Expanded(child: child),
],
);
}
}
+103 -63
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/json_utils.dart';
import '../l10n/l10n.dart';
import '../models/app_state.dart';
Future<void> showHabitDialog(
@@ -9,21 +10,26 @@ Future<void> showHabitDialog(
ValueChanged<String> onSubmit, {
String initialValue = '',
}) async {
final controller = TextEditingController(text: initialValue);
var value = initialValue;
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: Text(initialValue.isEmpty ? 'Add habit' : 'Rename habit'),
content: TextField(
controller: controller,
title: Text(
initialValue.isEmpty
? context.l10n.addHabitTitle
: context.l10n.renameHabitTitle,
),
content: TextFormField(
initialValue: initialValue,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Example: Read 10 pages',
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: context.l10n.habitHint,
),
onSubmitted: (value) {
onChanged: (nextValue) => value = nextValue,
onFieldSubmitted: (value) {
onSubmit(value);
Navigator.pop(context);
},
@@ -31,19 +37,63 @@ Future<void> showHabitDialog(
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onSubmit(controller.text);
onSubmit(value);
Navigator.pop(context);
},
child: const Text('Save'),
child: Text(context.l10n.save),
),
],
),
);
}
Future<void> showIntentionDialog(
BuildContext context,
ValueChanged<String> onSubmit, {
String initialValue = '',
}) async {
var value = initialValue;
await showDialog<void>(
context: context,
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),
),
],
),
);
controller.dispose();
}
Future<void> confirmDeleteHabit(
@@ -54,21 +104,19 @@ Future<void> confirmDeleteHabit(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Delete habit?'),
content: const Text(
'Past completion records for this habit will be removed.',
),
title: Text(context.l10n.deleteHabitTitle),
content: Text(context.l10n.deleteHabitBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: const Text('Delete'),
child: Text(context.l10n.delete),
),
],
),
@@ -76,79 +124,78 @@ Future<void> confirmDeleteHabit(
}
Future<void> showExportDialog(BuildContext context, AppState state) async {
final controller = TextEditingController(text: prettyJson(state.toJson()));
final scaffoldContext = context;
final export = prettyJson(state.toJson());
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Export JSON'),
title: Text(context.l10n.exportJson),
content: SizedBox(
width: double.maxFinite,
child: TextField(
controller: controller,
minLines: 8,
maxLines: 12,
readOnly: true,
decoration: const InputDecoration(border: OutlineInputBorder()),
child: SingleChildScrollView(
child: SelectableText(
export,
style: const TextStyle(fontFamily: 'monospace'),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(context.l10n.close),
),
FilledButton.icon(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: controller.text));
final message = context.l10n.exportCopied;
await Clipboard.setData(ClipboardData(text: export));
if (context.mounted) {
Navigator.pop(context);
showSnack(context, 'Export copied');
showSnack(scaffoldContext, message);
}
},
icon: const Icon(Icons.copy),
label: const Text('Copy'),
label: Text(context.l10n.copy),
),
],
),
);
controller.dispose();
}
Future<void> showImportDialog(
BuildContext context,
ValueChanged<String> onImport,
) async {
final controller = TextEditingController();
var value = '';
await showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Import JSON'),
content: TextField(
controller: controller,
title: Text(context.l10n.importJson),
content: TextFormField(
minLines: 8,
maxLines: 12,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Paste Focus Seed JSON export',
onChanged: (nextValue) => value = nextValue,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: context.l10n.importHint,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onImport(controller.text);
onImport(value);
Navigator.pop(context);
},
child: const Text('Import'),
child: Text(context.l10n.import),
),
],
),
);
controller.dispose();
}
Future<void> confirmClearData(
@@ -159,21 +206,19 @@ Future<void> confirmClearData(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Clear local data?'),
content: const Text(
'This resets habits, focus sessions, and reflections on this device.',
),
title: Text(context.l10n.clearDataTitle),
content: Text(context.l10n.clearDataBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: const Text('Clear'),
child: Text(context.l10n.clear),
),
],
),
@@ -189,23 +234,18 @@ void showPrivacySheet(BuildContext context) {
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: ListView(
shrinkWrap: true,
children: const [
children: [
Text(
'Privacy',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
),
SizedBox(height: 12),
Text(
'Focus Seed stores your anchor task, habits, focus sessions, '
'reflections, and settings locally on this device. V1 does not '
'include account sign-in, cloud sync, ads, analytics SDKs, or '
'billing SDKs.',
),
SizedBox(height: 12),
Text(
'You can export your local data as JSON, import a previous '
'export, or clear local data from Settings.',
context.l10n.privacyTitle,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
Text(context.l10n.privacyBodyOne),
const SizedBox(height: 12),
Text(context.l10n.privacyBodyTwo),
],
),
),
-37
View File
@@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
import 'seed_card.dart';
class EmptyState extends StatelessWidget {
const EmptyState({
required this.icon,
required this.title,
required this.action,
required this.onAction,
super.key,
});
final IconData icon;
final String title;
final String action;
final VoidCallback onAction;
@override
Widget build(BuildContext context) {
return SeedCard(
child: Column(
children: [
Icon(icon, size: 42, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 12),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.add),
label: Text(action),
),
],
),
);
}
}
+145
View File
@@ -0,0 +1,145 @@
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
@@ -0,0 +1,72 @@
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,
),
],
),
);
}
}
-77
View File
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import '../models/habit.dart';
import 'seed_card.dart';
class HabitRow extends StatelessWidget {
const HabitRow({
required this.habit,
required this.dateKey,
required this.onToggle,
this.onEdit,
this.onDelete,
super.key,
});
final Habit habit;
final String dateKey;
final VoidCallback onToggle;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final done = habit.completedDates.contains(dateKey);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.only(bottom: 10),
child: SeedCard(
child: Row(
children: [
IconButton.filledTonal(
tooltip: done ? 'Mark not done' : 'Mark done',
onPressed: onToggle,
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
child: Icon(
done ? Icons.check : Icons.circle_outlined,
key: ValueKey(done),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
habit.title,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
decoration: done ? TextDecoration.lineThrough : null,
color:
done
? Theme.of(context).colorScheme.onSurfaceVariant
: null,
),
),
),
if (onEdit != null)
PopupMenuButton<String>(
tooltip: 'Habit options',
onSelected: (value) {
if (value == 'edit') {
onEdit?.call();
}
if (value == 'delete') {
onDelete?.call();
}
},
itemBuilder:
(context) => const [
PopupMenuItem(value: 'edit', child: Text('Rename')),
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
),
);
}
}
-60
View File
@@ -1,60 +0,0 @@
import 'package:flutter/material.dart';
class InlineEditableText extends StatefulWidget {
const InlineEditableText({
required this.value,
required this.emptyText,
required this.onSubmitted,
super.key,
});
final String value;
final String emptyText;
final ValueChanged<String> onSubmitted;
@override
State<InlineEditableText> createState() => _InlineEditableTextState();
}
class _InlineEditableTextState extends State<InlineEditableText> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(covariant InlineEditableText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value && _controller.text != widget.value) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
textCapitalization: TextCapitalization.sentences,
onSubmitted: widget.onSubmitted,
onTapOutside: (_) => widget.onSubmitted(_controller.text),
decoration: InputDecoration(
hintText: widget.emptyText,
suffixIcon: IconButton(
tooltip: 'Save anchor',
onPressed: () => widget.onSubmitted(_controller.text),
icon: const Icon(Icons.check),
),
border: const OutlineInputBorder(),
),
);
}
}
-61
View File
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'seed_card.dart';
class MetricTile extends StatelessWidget {
const MetricTile({
required this.label,
required this.value,
required this.suffix,
required this.icon,
this.accent,
super.key,
});
final String label;
final String value;
final String suffix;
final IconData icon;
final Color? accent;
@override
Widget build(BuildContext context) {
final color = accent ?? Theme.of(context).colorScheme.primary;
return SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color),
const SizedBox(height: 12),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
if (suffix.isNotEmpty) ...[
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
suffix,
style: Theme.of(context).textTheme.labelMedium,
),
),
],
],
),
),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
),
);
}
}
-25
View File
@@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
class SectionLabel extends StatelessWidget {
const SectionLabel({required this.icon, required this.text, super.key});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(
text,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
],
);
}
}
-16
View File
@@ -1,16 +0,0 @@
import 'package:flutter/material.dart';
class SeedCard extends StatelessWidget {
const SeedCard({required this.child, this.margin, super.key});
final Widget child;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
return Card(
margin: margin,
child: Padding(padding: const EdgeInsets.all(16), child: child),
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import '../app/app_theme.dart';
class SurfacePanel extends StatelessWidget {
const SurfacePanel({
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin,
this.color,
this.borderColor,
this.elevated = false,
this.onTap,
super.key,
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final Color? color;
final Color? borderColor;
final bool elevated;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
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,
);
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),
),
);
}
}
-77
View File
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import '../core/date_keys.dart';
import '../models/app_state.dart';
import 'section_label.dart';
import 'seed_card.dart';
class WeeklyProgress extends StatelessWidget {
const WeeklyProgress({required this.state, super.key});
final AppState state;
@override
Widget build(BuildContext context) {
final days = lastSevenDateKeys();
return SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel(
icon: Icons.insights_outlined,
text: '7-day pulse',
),
const SizedBox(height: 14),
Row(
children:
days.map((day) {
final minutes = minutesForDate(state.sessions, day);
final habits =
state.habits
.where((habit) => habit.completedDates.contains(day))
.length;
final hasReflection = state.reflections.any(
(reflection) => reflection.dateKey == day,
);
final score =
(minutes > 0 ? 1 : 0) +
(habits > 0 ? 1 : 0) +
(hasReflection ? 1 : 0);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Column(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: 48 + (score * 8),
decoration: BoxDecoration(
color:
score == 0
? Theme.of(
context,
).colorScheme.surfaceContainerHighest
: Theme.of(
context,
).colorScheme.primary.withValues(
alpha: 0.35 + score * 0.15,
),
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(height: 6),
Text(
day.substring(5),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
);
}).toList(),
),
],
),
);
}
}