diff --git a/assets/images/growth/growth-flower.png b/assets/images/growth/growth-flower.png new file mode 100644 index 0000000..6187c3c Binary files /dev/null and b/assets/images/growth/growth-flower.png differ diff --git a/assets/images/growth/growth-leaves.png b/assets/images/growth/growth-leaves.png new file mode 100644 index 0000000..035debc Binary files /dev/null and b/assets/images/growth/growth-leaves.png differ diff --git a/assets/images/growth/growth-seed.png b/assets/images/growth/growth-seed.png new file mode 100644 index 0000000..dbbdbf3 Binary files /dev/null and b/assets/images/growth/growth-seed.png differ diff --git a/assets/images/growth/growth-sprout.png b/assets/images/growth/growth-sprout.png new file mode 100644 index 0000000..e648ffe Binary files /dev/null and b/assets/images/growth/growth-sprout.png differ diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..540082b --- /dev/null +++ b/design-qa.md @@ -0,0 +1,35 @@ +# Focus Seed 1.0.1 Design QA + +## Inputs + +- Selected reference: `docs/design/focus-seed-visual-direction-v1.0.1.png` +- Ambience reference: `docs/design/focus-seed-living-ambience-v1.0.1.png` +- Device: Pixel 5 Android emulator, 1080 x 2340, density 440 + +## Captured States + +- Empty Today: `launch/screenshots/focus-seed-101-today-v2.png` +- Intention saved: `launch/screenshots/focus-seed-101-intention.png` +- Running timer: `launch/screenshots/focus-seed-101-running-final.png` +- Background restored: `launch/screenshots/focus-seed-101-restored-final.png` +- Journey: `launch/screenshots/focus-seed-101-journey.png` +- Dark appearance: `launch/screenshots/focus-seed-101-dark.png` + +## Findings + +- P0 fixed: the initial focus card stacked vertically and read as oversized. The normal-phone layout now keeps time and growth side by side and reduces empty-state copy. +- P0 fixed: light status-bar icons lacked contrast. System UI brightness now follows app brightness. +- P1 fixed: intention dialogs disposed their controller during route dismissal. Dialog fields now own their value without an early-dispose race. +- P1 passed: timer restores from the absolute end time after backgrounding. +- P1 passed: content scrolls above persistent navigation without overlap. +- P1 passed: Journey communicates lifecycle order and incomplete states without relying on color alone. +- P1 passed: dark appearance preserves hierarchy and control contrast. +- P1 passed: a 320 x 700 viewport at 200% text scale renders without framework overflow or exception. +- P2 passed: empty, populated, running, restored, completed-habit, and dark states use the same component grammar. + +## Residual P3 Notes + +- English is the only in-app language in 1.0.1; future localization should use Flutter ARB generation before adding translated store regions. +- Tablet-specific two-column composition is deferred; the current layout remains centered and scrollable on larger widths. + +final result: passed diff --git a/docs/architecture/v1.0.1-architecture.md b/docs/architecture/v1.0.1-architecture.md new file mode 100644 index 0000000..c71ecba --- /dev/null +++ b/docs/architecture/v1.0.1-architecture.md @@ -0,0 +1,57 @@ +# Focus Seed 1.0.1 Architecture + +## Decision + +Keep the app offline-first and SDK-light. Flutter renders all product UI. `shared_preferences` remains the persistence adapter because the current dataset is one small versioned document and does not require relational queries, pagination, joins, or concurrent writers. + +Room is Android-only and would break the iOS-compatible boundary. MMKV adds a native dependency without solving a current bottleneck. If usage grows to hundreds of timeline records or indexed queries, migrate behind `FocusSeedStore` to an embedded cross-platform database without changing feature code. + +## Layers + +```text +lib/src/ + app/ Material theme, ambience integration, app lifecycle + application/ User commands and timer lifecycle controllers + core/ Pure date and ambience rules + data/ Persistence adapters + models/ Serializable state and derived domain models + features/ Today, Journey, Me, habits, reflection, shell + services/ Analytics and monetization interfaces/no-op adapters + widgets/ Reusable visual primitives +``` + +Rules: + +- Widgets render state and forward intent; they do not perform persistence. +- `FocusSeedController` owns durable product commands and optimistic save rollback. +- `FocusTimerController` owns only timer lifecycle and absolute-time restoration. +- `FocusSeedStore` is the only durable app-state boundary. +- External analytics, ads, billing, and remote configuration remain interfaces with no-op implementations until their SDK, consent, privacy, and policy work is approved together. + +## Navigation + +Use three top-level destinations: + +- Today: intention, timer, habits, reflection, and immediate daily progress. +- Journey: daily lifecycle, recent sessions, reflections, and seven-day growth. +- Me: appearance, privacy, import/export, and reset. + +Timer, habits, and reflection remain independent modules. They are actions inside the daily workflow instead of equal navigation destinations. This reduces context switching and preserves one primary task per screen. A future usage study may justify configurable shortcuts, but it should not restore five equal tabs by default. + +## Performance Contract + +- No network, location, background service, broadcast receiver, or foreground service. +- One timer ticks only while a focus session is active and is disposed with the shell. +- Background restoration derives remaining time from an absolute end timestamp; no background polling is used. +- Day ambience schedules one callback at the next phase boundary and refreshes on resume. +- Growth assets are fixed 640 px transparent PNGs and only transition when the domain stage changes. +- Timer rebuilds stay inside `FocusSessionCard`; durable state changes rebuild the shell. +- Long content uses lazy or bounded scrolling. Today shows four habits before offering the full manager. +- Reduced motion replaces scale transitions with a crossfade. + +## Compatibility And Migration + +- Existing version 1 JSON loads through `AppState.fromJson` and is saved as version 2. +- The package name, signing setup, version, permissions, and privacy posture are unchanged. +- Android is the release target; architecture and dependencies remain compatible with a later iOS build. +- Release artifacts must pass format, analyze, tests, manifest review, app bundle build, and emulator smoke testing. diff --git a/docs/design/focus-seed-living-ambience-v1.0.1.png b/docs/design/focus-seed-living-ambience-v1.0.1.png new file mode 100644 index 0000000..2054c93 Binary files /dev/null and b/docs/design/focus-seed-living-ambience-v1.0.1.png differ diff --git a/docs/design/focus-seed-visual-direction-v1.0.1.png b/docs/design/focus-seed-visual-direction-v1.0.1.png new file mode 100644 index 0000000..c84c748 Binary files /dev/null and b/docs/design/focus-seed-visual-direction-v1.0.1.png differ diff --git a/docs/design/visual-direction-v1.0.1.md b/docs/design/visual-direction-v1.0.1.md new file mode 100644 index 0000000..75240c7 --- /dev/null +++ b/docs/design/visual-direction-v1.0.1.md @@ -0,0 +1,117 @@ +# Focus Seed Visual Direction 1.0.1 + +## Decision + +Use an editorial botanical interface with premium Soft 3D growth objects and state-driven Material Expressive motion. + +The interface remains a mature productivity product. Illustration and 3D are reserved for progress, state, onboarding, empty states, and completion feedback rather than ordinary controls. + +## Product Expression + +- Core metaphor: intention plants a seed; focus, habits, and reflection grow it. +- Navigation: Today, Journey, Me. +- Today expands the current next action and compresses completed or future stages. +- Journey shows the relationship between daily actions and growth rather than a generic statistics dashboard. +- Ordinary lists, forms, dialogs, and settings stay compact and familiar. + +## Visual Balance + +- 72% functional editorial UI +- 18% Soft 3D state objects +- 10% botanical illustration, semantic accent, and motion + +## Foundation Tokens + +- Porcelain: `#F7F8F5` +- Ink: `#17211C` +- Leaf: `#4D8B63` +- Mint: `#A9E8B5` +- Ultramarine: `#4E63D8` +- Coral: `#EA725E` +- Radii: `8`, `12`, `16` +- Spacing: four-point base with an eight-point layout rhythm +- Elevation: two restrained levels plus hairline separators +- Body type: 14-16 logical pixels with compact headings + +## Growth States + +- Seed: no intention +- Sprout: intention saved +- Leaves: focus and habit progress +- Flower: reflection complete + +Every state needs a non-color cue. Reduced-motion mode replaces growth animation with crossfade and an immediate state swap. + +## Living Ambience System + +Use stable structure with slowly changing ambience. Time must never move controls, change navigation, alter semantic meaning, or reduce contrast. + +### Local Time Modes + +- Dawn, `05:00-09:59`: cool porcelain, pale sky-mint surface tint, low-stimulation entrance, dew highlight on seed or sprout. +- Day, `10:00-16:59`: clean porcelain, highest information clarity, leaf and ultramarine accents. +- Dusk, `17:00-20:59`: restrained coral-lilac tint, softer localized shadows, reflection receives more visual priority when pending. +- Night, `21:00-04:59`: warm graphite, off-white text, mint primary, muted violet reflection accent, no bright glow. + +Determine the mode from the device clock. Do not request location or network access. Re-evaluate on launch, resume, and the next time boundary; never run a background polling loop. + +### Priority Order + +1. Accessibility and contrast requirements +2. Explicit user Light, Dark, or System appearance preference +3. Platform reduced-motion and high-contrast settings +4. Time ambience within the selected appearance +5. Product growth state + +If the user forces Light or Dark, preserve that appearance and vary only compatible neutral tints and object lighting. System appearance always wins over an automatic day/night surface switch. + +### Week And Season + +- Weekday/weekend may adjust optional greeting copy and recommended session length, but must not rearrange the interface. +- Do not infer physical season from month alone for a global audience because hemispheres differ. +- Offer optional Growth Seasons as cosmetic progression chapters. They are driven by product progress or an explicit user choice, not location. +- Avoid weather, religious, national, and holiday imagery unless the user explicitly enables a localized pack. + +## Motion Contract + +- Press response: `120ms` +- Ordinary state transition: `180-240ms` +- Growth transformation: `360-480ms` +- One-time completion celebration: no more than `700ms` +- Page transition: preserve spatial continuity and remain under `300ms` + +Motion events: + +- Intention saved: seed rises, settles, and becomes a sprout. +- Focus starts: progress begins; plant movement is nearly imperceptible. +- Habit completes: checkbox morphs to a leaf and one leaf unfolds. +- Reflection saves: flower opens once, then remains still. +- Background restoration: timer and plant immediately snap to correct data, followed by a short confirmation. + +Never run continuous decorative motion. Pause nonessential animation when the app is inactive, during scrolling, under battery-saving conditions, or when reduced motion is enabled. Reduced motion uses crossfade, opacity, and immediate state replacement. + +## Variation Budget + +- Structure and navigation: `0%` time-based variation +- Semantic colors and action meaning: `0%` variation +- Neutral surfaces and background tint: subtle variation only +- Plant state: driven by real user progress +- Illustration: reserved for onboarding, empty states, milestones, and completion +- Copy: may vary by time while preserving the same meaning and action + +The goal is recognition with freshness, not novelty on every launch. + +## Guardrails + +- No childish faces or mascot behavior. +- No oversized 3D hero or giant timer. +- No card-per-section dashboard. +- No decorative illustration without state meaning. +- No animation that delays input or hides status. +- Validate long localization, 200% text, dense lists, keyboard, offline restoration, loading, and recoverable errors. + +## Visual Reference + +See `focus-seed-visual-direction-v1.0.1.png` in this directory. + +See `focus-seed-living-ambience-v1.0.1.png` for time modes and motion behavior. diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..554eb2b --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,6 @@ +arb-dir: lib/src/l10n +template-arb-file: app_en.arb +output-dir: lib/src/l10n/generated +output-localization-file: app_localizations.dart +nullable-getter: false +synthetic-package: false diff --git a/launch/screenshots/focus-seed-101-dark.png b/launch/screenshots/focus-seed-101-dark.png new file mode 100644 index 0000000..560d3a4 Binary files /dev/null and b/launch/screenshots/focus-seed-101-dark.png differ diff --git a/launch/screenshots/focus-seed-101-intention.png b/launch/screenshots/focus-seed-101-intention.png new file mode 100644 index 0000000..13404f5 Binary files /dev/null and b/launch/screenshots/focus-seed-101-intention.png differ diff --git a/launch/screenshots/focus-seed-101-journey.png b/launch/screenshots/focus-seed-101-journey.png new file mode 100644 index 0000000..519e093 Binary files /dev/null and b/launch/screenshots/focus-seed-101-journey.png differ diff --git a/launch/screenshots/focus-seed-101-restored-final.png b/launch/screenshots/focus-seed-101-restored-final.png new file mode 100644 index 0000000..a0c65c8 Binary files /dev/null and b/launch/screenshots/focus-seed-101-restored-final.png differ diff --git a/launch/screenshots/focus-seed-101-running-final.png b/launch/screenshots/focus-seed-101-running-final.png new file mode 100644 index 0000000..902fb2e Binary files /dev/null and b/launch/screenshots/focus-seed-101-running-final.png differ diff --git a/launch/screenshots/focus-seed-101-today-v2.png b/launch/screenshots/focus-seed-101-today-v2.png new file mode 100644 index 0000000..850ba6a Binary files /dev/null and b/launch/screenshots/focus-seed-101-today-v2.png differ diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index 002c900..2039c3e 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -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 { + 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()!; + } + + 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)!, ); } } diff --git a/lib/src/app/focus_seed_app.dart b/lib/src/app/focus_seed_app.dart index 6f71154..9395eed 100644 --- a/lib/src/app/focus_seed_app.dart +++ b/lib/src/app/focus_seed_app.dart @@ -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 createState() => _FocusSeedAppState(); +} + +class _FocusSeedAppState extends State + 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 _loadPreferences() async { + final appearance = await _preferencesStore.loadAppearance(); + final language = await _preferencesStore.loadLanguage(); + if (!mounted) { + return; + } + setState(() { + _appearance = appearance; + _language = language; + }); + } + + Future _setAppearance(AppearancePreference appearance) async { + setState(() => _appearance = appearance); + await _preferencesStore.saveAppearance(appearance); + } + + Future _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(); + } } diff --git a/lib/src/application/focus_seed_controller.dart b/lib/src/application/focus_seed_controller.dart new file mode 100644 index 0000000..2a35e5d --- /dev/null +++ b/lib/src/application/focus_seed_controller.dart @@ -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 defaultHabitTitles = const [], + }) : _store = store ?? FocusSeedStore(), + _analytics = analytics ?? const NoopAnalyticsProvider(), + _defaultHabitTitles = List.unmodifiable(defaultHabitTitles); + + final FocusSeedStore _store; + final AnalyticsProvider _analytics; + List _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 titles) { + _defaultHabitTitles = List.unmodifiable(titles); + } + + Future load() async { + _isLoading = true; + _error = null; + notifyListeners(); + try { + _state = await _store.load(defaultHabitTitles: _defaultHabitTitles); + } on Object { + _error = FocusSeedError.openFailed; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future 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 toggleHabit(Habit habit) async { + final current = _state; + if (current == null) { + return false; + } + final today = todayKey(); + final completedDates = Set.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 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 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 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 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 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 importState(String raw) async { + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return false; + } + return _commit(AppState.fromJson(decoded)); + } on Object { + return false; + } + } + + Future clearData() async { + return _commit(AppState.starter(defaultHabitTitles: _defaultHabitTitles)); + } + + Future _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(); + } + } +} diff --git a/lib/src/application/focus_timer_controller.dart b/lib/src/application/focus_timer_controller.dart new file mode 100644 index 0000000..278387e --- /dev/null +++ b/lib/src/application/focus_timer_controller.dart @@ -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 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 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 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 _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(); + } +} diff --git a/lib/src/core/ambience.dart b/lib/src/core/ambience.dart new file mode 100644 index 0000000..43baa97 --- /dev/null +++ b/lib/src/core/ambience.dart @@ -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; +} diff --git a/lib/src/core/date_keys.dart b/lib/src/core/date_keys.dart index f23782e..cc0931e 100644 --- a/lib/src/core/date_keys.dart +++ b/lib/src/core/date_keys.dart @@ -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 lastSevenDateKeys() { final now = DateTime.now(); return List.generate( diff --git a/lib/src/data/app_preferences_store.dart b/lib/src/data/app_preferences_store.dart new file mode 100644 index 0000000..8430a26 --- /dev/null +++ b/lib/src/data/app_preferences_store.dart @@ -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 loadAppearance() async { + final preferences = await SharedPreferences.getInstance(); + return appearancePreferenceFromName(preferences.getString(_appearanceKey)); + } + + Future saveAppearance(AppearancePreference appearance) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString(_appearanceKey, appearance.name); + } + + Future loadLanguage() async { + final preferences = await SharedPreferences.getInstance(); + return languagePreferenceFromName(preferences.getString(_languageKey)); + } + + Future saveLanguage(LanguagePreference language) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString(_languageKey, language.name); + } +} diff --git a/lib/src/data/focus_seed_store.dart b/lib/src/data/focus_seed_store.dart index b284aff..732766e 100644 --- a/lib/src/data/focus_seed_store.dart +++ b/lib/src/data/focus_seed_store.dart @@ -8,16 +8,16 @@ import '../models/app_state.dart'; class FocusSeedStore { static const _storageKey = 'focus_seed_state_v1'; - Future load() async { + Future load({List? 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); } on Object { - return AppState.starter(); + return AppState.starter(defaultHabitTitles: defaultHabitTitles); } } diff --git a/lib/src/features/habits/habit_manager_sheet.dart b/lib/src/features/habits/habit_manager_sheet.dart new file mode 100644 index 0000000..1cf37a3 --- /dev/null +++ b/lib/src/features/habits/habit_manager_sheet.dart @@ -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 showHabitManagerSheet( + BuildContext context, + FocusSeedController controller, +) { + return showModalBottomSheet( + 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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/habits/habits_screen.dart b/lib/src/features/habits/habits_screen.dart deleted file mode 100644 index 06b065b..0000000 --- a/lib/src/features/habits/habits_screen.dart +++ /dev/null @@ -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 onAddHabit; - final void Function(Habit habit, String title) onRenameHabit; - final ValueChanged onDeleteHabit; - final ValueChanged 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)), - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/features/home/home_screen.dart b/lib/src/features/home/home_screen.dart deleted file mode 100644 index ffa0a5f..0000000 --- a/lib/src/features/home/home_screen.dart +++ /dev/null @@ -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 onAnchorChanged; - final VoidCallback onOpenTimer; - final ValueChanged onToggleHabit; - final ValueChanged 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), - ], - ), - ); - } -} diff --git a/lib/src/features/journey/journey_screen.dart b/lib/src/features/journey/journey_screen.dart new file mode 100644 index 0000000..53aad7b --- /dev/null +++ b/lib/src/features/journey/journey_screen.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/lib/src/features/journey/widgets/growth_week_strip.dart b/lib/src/features/journey/widgets/growth_week_strip.dart new file mode 100644 index 0000000..3a3a84a --- /dev/null +++ b/lib/src/features/journey/widgets/growth_week_strip.dart @@ -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), + ], + ), + ); + } +} diff --git a/lib/src/features/journey/widgets/journey_timeline.dart b/lib/src/features/journey/widgets/journey_timeline.dart new file mode 100644 index 0000000..3f3a349 --- /dev/null +++ b/lib/src/features/journey/widgets/journey_timeline.dart @@ -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; +} diff --git a/lib/src/features/me/me_screen.dart b/lib/src/features/me/me_screen.dart new file mode 100644 index 0000000..debaa46 --- /dev/null +++ b/lib/src/features/me/me_screen.dart @@ -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 onAppearanceChanged; + final LanguagePreference language; + final bool showLanguageTools; + final ValueChanged 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( + 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( + 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, + ), + ), + ], + ); + } +} diff --git a/lib/src/features/reflection/reflection_screen.dart b/lib/src/features/reflection/reflection_screen.dart deleted file mode 100644 index c5ad99d..0000000 --- a/lib/src/features/reflection/reflection_screen.dart +++ /dev/null @@ -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 createState() => _ReflectionScreenState(); -} - -class _ReflectionScreenState extends State { - 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), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/features/reflection/reflection_sheet.dart b/lib/src/features/reflection/reflection_sheet.dart new file mode 100644 index 0000000..6bce2e5 --- /dev/null +++ b/lib/src/features/reflection/reflection_sheet.dart @@ -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 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( + 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 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 _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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/settings/settings_screen.dart b/lib/src/features/settings/settings_screen.dart deleted file mode 100644 index af33108..0000000 --- a/lib/src/features/settings/settings_screen.dart +++ /dev/null @@ -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 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), - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/features/shell/focus_seed_home.dart b/lib/src/features/shell/focus_seed_home.dart index 169e762..213c801 100644 --- a/lib/src/features/shell/focus_seed_home.dart +++ b/lib/src/features/shell/focus_seed_home.dart @@ -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 onAppearanceChanged; + final LanguagePreference language; + final bool showLanguageTools; + final ValueChanged onLanguageChanged; @override State createState() => _FocusSeedHomeState(); } class _FocusSeedHomeState extends State { - final _store = FocusSeedStore(); - final _analytics = const NoopAnalyticsProvider(); - AppState? _state; + FocusSeedController? _controller; + FocusTimerController? _timerController; int _tabIndex = 0; @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - final state = await _store.load(); - if (!mounted) { + void didChangeDependencies() { + super.didChangeDependencies(); + if (_controller != null) { return; } - setState(() => _state = state); - } - - Future _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( + 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 _toggleHabit(Habit habit) async { - final state = _state; - if (state == null) { - return; - } - final today = todayKey(); - final completedDates = Set.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 _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 _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 _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 _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 _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 _importState(String raw) async { - try { - final imported = AppState.fromJson( - jsonDecode(raw) as Map, - ); - await _setStateAndSave(imported); - if (mounted) { - showSnack(context, 'Data imported'); - } - } on Object { - if (mounted) { - showSnack(context, 'Import failed. Check the JSON and try again.'); - } - } - } - - Future _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, + }; +} diff --git a/lib/src/features/timer/timer_screen.dart b/lib/src/features/timer/timer_screen.dart deleted file mode 100644 index 82591ea..0000000 --- a/lib/src/features/timer/timer_screen.dart +++ /dev/null @@ -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 onComplete; - final VoidCallback onReflect; - - @override - State createState() => _TimerScreenState(); -} - -class _TimerScreenState extends State { - 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( - 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( - 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'), - ), - ], - ), - ); - } -} diff --git a/lib/src/features/today/today_screen.dart b/lib/src/features/today/today_screen.dart new file mode 100644 index 0000000..8fe8604 --- /dev/null +++ b/lib/src/features/today/today_screen.dart @@ -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), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/today/widgets/compact_habit_row.dart b/lib/src/features/today/widgets/compact_habit_row.dart new file mode 100644 index 0000000..878e24d --- /dev/null +++ b/lib/src/features/today/widgets/compact_habit_row.dart @@ -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( + 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), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/features/today/widgets/daily_progress_rail.dart b/lib/src/features/today/widgets/daily_progress_rail.dart new file mode 100644 index 0000000..2b4d45d --- /dev/null +++ b/lib/src/features/today/widgets/daily_progress_rail.dart @@ -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; +} diff --git a/lib/src/features/today/widgets/focus_session_card.dart b/lib/src/features/today/widgets/focus_session_card.dart new file mode 100644 index 0000000..67c17d7 --- /dev/null +++ b/lib/src/features/today/widgets/focus_session_card.dart @@ -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( + 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), + ), + ], + ], + ); + } +} diff --git a/lib/src/features/today/widgets/habit_section.dart b/lib/src/features/today/widgets/habit_section.dart new file mode 100644 index 0000000..2dff4ea --- /dev/null +++ b/lib/src/features/today/widgets/habit_section.dart @@ -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 habits; + final String dateKey; + final ValueChanged 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)), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/src/features/today/widgets/intention_card.dart b/lib/src/features/today/widgets/intention_card.dart new file mode 100644 index 0000000..bb0af0c --- /dev/null +++ b/lib/src/features/today/widgets/intention_card.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/lib/src/features/today/widgets/reflection_prompt.dart b/lib/src/features/today/widgets/reflection_prompt.dart new file mode 100644 index 0000000..50be749 --- /dev/null +++ b/lib/src/features/today/widgets/reflection_prompt.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/lib/src/l10n/app_en.arb b/lib/src/l10n/app_en.arb new file mode 100644 index 0000000..831ef85 --- /dev/null +++ b/lib/src/l10n/app_en.arb @@ -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" +} diff --git a/lib/src/l10n/app_zh.arb b/lib/src/l10n/app_zh.arb new file mode 100644 index 0000000..3851373 --- /dev/null +++ b/lib/src/l10n/app_zh.arb @@ -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" +} diff --git a/lib/src/l10n/generated/app_localizations.dart b/lib/src/l10n/generated/app_localizations.dart new file mode 100644 index 0000000..59936fa --- /dev/null +++ b/lib/src/l10n/generated/app_localizations.dart @@ -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(context, AppLocalizations)!; + } + + static const LocalizationsDelegate 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> localizationsDelegates = >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + 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 { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => ['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.' + ); +} diff --git a/lib/src/l10n/generated/app_localizations_en.dart b/lib/src/l10n/generated/app_localizations_en.dart new file mode 100644 index 0000000..fa7f48c --- /dev/null +++ b/lib/src/l10n/generated/app_localizations_en.dart @@ -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'; +} diff --git a/lib/src/l10n/generated/app_localizations_zh.dart b/lib/src/l10n/generated/app_localizations_zh.dart new file mode 100644 index 0000000..20e2c45 --- /dev/null +++ b/lib/src/l10n/generated/app_localizations_zh.dart @@ -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 => '写一句复盘'; +} diff --git a/lib/src/l10n/l10n.dart b/lib/src/l10n/l10n.dart new file mode 100644 index 0000000..72d7f98 --- /dev/null +++ b/lib/src/l10n/l10n.dart @@ -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 localizedStarterHabits(AppLocalizations l10n) { + return [ + l10n.defaultHabitPlanTask, + l10n.defaultHabitFocus, + l10n.defaultHabitReflect, + ]; +} diff --git a/lib/src/models/app_state.dart b/lib/src/models/app_state.dart index 43b89aa..41fff17 100644 --- a/lib/src/models/app_state.dart +++ b/lib/src/models/app_state.dart @@ -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 habits; final List sessions; final List 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? habits, List? sessions, List? 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 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 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>(); } - static AppState starter() { + static AppState starter({List? 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')}'; + } } diff --git a/lib/src/models/appearance_preference.dart b/lib/src/models/appearance_preference.dart new file mode 100644 index 0000000..f85e8f0 --- /dev/null +++ b/lib/src/models/appearance_preference.dart @@ -0,0 +1,8 @@ +enum AppearancePreference { system, light, dark } + +AppearancePreference appearancePreferenceFromName(String? name) { + return AppearancePreference.values.firstWhere( + (preference) => preference.name == name, + orElse: () => AppearancePreference.system, + ); +} diff --git a/lib/src/models/growth_stage.dart b/lib/src/models/growth_stage.dart new file mode 100644 index 0000000..9d5d290 --- /dev/null +++ b/lib/src/models/growth_stage.dart @@ -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', + }; +} diff --git a/lib/src/models/language_preference.dart b/lib/src/models/language_preference.dart new file mode 100644 index 0000000..a5249ba --- /dev/null +++ b/lib/src/models/language_preference.dart @@ -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'), + }; +} diff --git a/lib/src/models/mood.dart b/lib/src/models/mood.dart index d7166b7..a89a69f 100644 --- a/lib/src/models/mood.dart +++ b/lib/src/models/mood.dart @@ -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; } diff --git a/lib/src/models/today_progress.dart b/lib/src/models/today_progress.dart new file mode 100644 index 0000000..73bd135 --- /dev/null +++ b/lib/src/models/today_progress.dart @@ -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; +} diff --git a/lib/src/widgets/app_scaffold.dart b/lib/src/widgets/app_scaffold.dart deleted file mode 100644 index ede6f5d..0000000 --- a/lib/src/widgets/app_scaffold.dart +++ /dev/null @@ -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), - ], - ); - } -} diff --git a/lib/src/widgets/dialogs.dart b/lib/src/widgets/dialogs.dart index 4ebd6e0..ccdc3db 100644 --- a/lib/src/widgets/dialogs.dart +++ b/lib/src/widgets/dialogs.dart @@ -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 showHabitDialog( @@ -9,21 +10,26 @@ Future showHabitDialog( ValueChanged onSubmit, { String initialValue = '', }) async { - final controller = TextEditingController(text: initialValue); + var value = initialValue; await showDialog( 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 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 showIntentionDialog( + BuildContext context, + ValueChanged onSubmit, { + String initialValue = '', +}) async { + var value = initialValue; + await showDialog( + 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 confirmDeleteHabit( @@ -54,21 +104,19 @@ Future 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 confirmDeleteHabit( } Future showExportDialog(BuildContext context, AppState state) async { - final controller = TextEditingController(text: prettyJson(state.toJson())); + final scaffoldContext = context; + final export = prettyJson(state.toJson()); await showDialog( 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 showImportDialog( BuildContext context, ValueChanged onImport, ) async { - final controller = TextEditingController(); + var value = ''; await showDialog( 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 confirmClearData( @@ -159,21 +206,19 @@ Future 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), ], ), ), diff --git a/lib/src/widgets/empty_state.dart b/lib/src/widgets/empty_state.dart deleted file mode 100644 index 6de6d5b..0000000 --- a/lib/src/widgets/empty_state.dart +++ /dev/null @@ -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), - ), - ], - ), - ); - } -} diff --git a/lib/src/widgets/focus_seed_header.dart b/lib/src/widgets/focus_seed_header.dart new file mode 100644 index 0000000..bc6bb49 --- /dev/null +++ b/lib/src/widgets/focus_seed_header.dart @@ -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), + ], + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/growth_object.dart b/lib/src/widgets/growth_object.dart new file mode 100644 index 0000000..a1e589a --- /dev/null +++ b/lib/src/widgets/growth_object.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/habit_row.dart b/lib/src/widgets/habit_row.dart deleted file mode 100644 index d78cd42..0000000 --- a/lib/src/widgets/habit_row.dart +++ /dev/null @@ -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( - 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')), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/widgets/inline_editable_text.dart b/lib/src/widgets/inline_editable_text.dart deleted file mode 100644 index 00b12dc..0000000 --- a/lib/src/widgets/inline_editable_text.dart +++ /dev/null @@ -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 onSubmitted; - - @override - State createState() => _InlineEditableTextState(); -} - -class _InlineEditableTextState extends State { - 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(), - ), - ); - } -} diff --git a/lib/src/widgets/metric_tile.dart b/lib/src/widgets/metric_tile.dart deleted file mode 100644 index 7e6f97c..0000000 --- a/lib/src/widgets/metric_tile.dart +++ /dev/null @@ -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), - ], - ), - ); - } -} diff --git a/lib/src/widgets/section_label.dart b/lib/src/widgets/section_label.dart deleted file mode 100644 index 3b1849e..0000000 --- a/lib/src/widgets/section_label.dart +++ /dev/null @@ -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, - ), - ), - ], - ); - } -} diff --git a/lib/src/widgets/seed_card.dart b/lib/src/widgets/seed_card.dart deleted file mode 100644 index dcd3b39..0000000 --- a/lib/src/widgets/seed_card.dart +++ /dev/null @@ -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), - ); - } -} diff --git a/lib/src/widgets/surface_panel.dart b/lib/src/widgets/surface_panel.dart new file mode 100644 index 0000000..8b32397 --- /dev/null +++ b/lib/src/widgets/surface_panel.dart @@ -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), + ), + ); + } +} diff --git a/lib/src/widgets/weekly_progress.dart b/lib/src/widgets/weekly_progress.dart deleted file mode 100644 index c786ac0..0000000 --- a/lib/src/widgets/weekly_progress.dart +++ /dev/null @@ -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(), - ), - ], - ), - ); - } -} diff --git a/pubspec.lock b/pubspec.lock index fd253df..88cf3a4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -86,6 +86,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -96,6 +101,14 @@ packages: description: flutter source: sdk version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.19.0" leak_tracker: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f694c56..747a580 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,10 +30,13 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + intl: any shared_preferences: ^2.5.3 dev_dependencies: @@ -52,12 +55,16 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + assets: + - assets/images/growth/ + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/test/ambience_test.dart b/test/ambience_test.dart new file mode 100644 index 0000000..541e193 --- /dev/null +++ b/test/ambience_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:focus_seed/src/core/ambience.dart'; + +void main() { + group('dayPhaseFor', () { + test('uses the documented local time boundaries', () { + expect(dayPhaseFor(DateTime(2026, 6, 11, 4, 59)), DayPhase.night); + expect(dayPhaseFor(DateTime(2026, 6, 11, 5)), DayPhase.dawn); + expect(dayPhaseFor(DateTime(2026, 6, 11, 10)), DayPhase.day); + expect(dayPhaseFor(DateTime(2026, 6, 11, 17)), DayPhase.dusk); + expect(dayPhaseFor(DateTime(2026, 6, 11, 21)), DayPhase.night); + }); + + test('schedules the next boundary without background polling', () { + expect( + nextDayPhaseBoundary(DateTime(2026, 6, 11, 21, 15)), + DateTime(2026, 6, 12, 5), + ); + expect( + nextDayPhaseBoundary(DateTime(2026, 6, 11, 8)), + DateTime(2026, 6, 11, 10), + ); + }); + }); +} diff --git a/test/growth_stage_test.dart b/test/growth_stage_test.dart new file mode 100644 index 0000000..b86ff5e --- /dev/null +++ b/test/growth_stage_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:focus_seed/src/models/app_state.dart'; +import 'package:focus_seed/src/models/focus_session.dart'; +import 'package:focus_seed/src/models/growth_stage.dart'; +import 'package:focus_seed/src/models/habit.dart'; +import 'package:focus_seed/src/models/mood.dart'; +import 'package:focus_seed/src/models/reflection_entry.dart'; + +void main() { + const key = '2026-06-11'; + final date = DateTime(2026, 6, 11, 10); + + test('growth stage follows real daily progress', () { + const seed = AppState( + anchor: '', + anchorDateKey: key, + habits: [], + sessions: [], + reflections: [], + ); + expect(growthStageFor(seed, key), GrowthStage.seed); + + final sprout = seed.copyWith(anchor: 'Ship one useful thing'); + expect(growthStageFor(sprout, key), GrowthStage.sprout); + + final leaves = sprout.copyWith( + sessions: [FocusSession(id: 'session-1', minutes: 25, completedAt: date)], + habits: [ + Habit( + id: 'habit-1', + title: 'Plan one task', + createdAt: date, + completedDates: const {key}, + ), + ], + ); + expect(growthStageFor(leaves, key), GrowthStage.leaves); + + final flower = leaves.copyWith( + reflections: [ + ReflectionEntry( + id: 'reflection-1', + dateKey: key, + note: 'A smaller scope made the work clearer.', + mood: Mood.clear, + createdAt: date, + ), + ], + ); + expect(growthStageFor(flower, key), GrowthStage.flower); + }); + + test('version one anchor data migrates to the current day', () { + final state = AppState.fromJson(const { + 'version': 1, + 'anchor': 'Legacy intention', + 'habits': [], + 'sessions': [], + 'reflections': [], + }); + + expect(state.anchorDateKey, isNotEmpty); + expect(state.toJson()['version'], 2); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index bf911a2..38a5ecb 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,16 +1,104 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:focus_seed/src/app/focus_seed_app.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { - testWidgets('Focus Seed opens to the today screen', (tester) async { + setUp(() { SharedPreferences.setMockInitialValues({}); + }); + test('locale policy defaults to English for release and Chinese for dev', () { + expect(focusSeedDefaultLocale(isRelease: true), const Locale('en')); + expect(focusSeedDefaultLocale(), const Locale('zh')); + }); + + testWidgets('Today guides the user from intention to focus', (tester) async { + await tester.pumpWidget(const FocusSeedApp(locale: Locale('en'))); + await tester.pumpAndSettle(); + + expect(find.text('Focus Seed'), findsOneWidget); + expect(find.text("TODAY'S INTENTION"), findsOneWidget); + expect(find.text("Set today's intention"), findsOneWidget); + + await tester.tap(find.text('Choose one outcome')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byType(TextField), + 'Finish the launch checklist', + ); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Finish the launch checklist'), findsOneWidget); + expect(find.text('Start 25 min'), findsOneWidget); + }); + + testWidgets('bottom navigation exposes Journey and Me', (tester) async { + await tester.pumpWidget(const FocusSeedApp(locale: Locale('en'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Journey')); + await tester.pumpAndSettle(); + expect(find.text("Today's journey"), findsOneWidget); + + await tester.tap(find.text('Me')); + await tester.pumpAndSettle(); + expect(find.text('Your space'), findsOneWidget); + expect(find.text('Appearance'), findsOneWidget); + }); + + testWidgets('Today remains usable at narrow width and 200 percent text', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 700); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + tester.platformDispatcher.clearTextScaleFactorTestValue(); + }); + + await tester.pumpWidget(const FocusSeedApp(locale: Locale('en'))); + await tester.pumpAndSettle(); + + expect(find.text('Focus Seed'), findsOneWidget); + expect(find.byType(Scrollable), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('development default uses concise Chinese copy', (tester) async { await tester.pumpWidget(const FocusSeedApp()); await tester.pumpAndSettle(); expect(find.text('Focus Seed'), findsOneWidget); - expect(find.text('Today anchor'), findsOneWidget); - expect(find.text('Start focus'), findsOneWidget); + expect(find.text('\u4eca\u65e5\u76ee\u6807'), findsOneWidget); + expect(find.text('\u8bbe\u7f6e\u4eca\u65e5\u76ee\u6807'), findsOneWidget); + await tester.scrollUntilVisible( + find.text('\u8ba1\u5212\u4e00\u4e2a\u660e\u786e\u4efb\u52a1'), + 180, + ); + expect( + find.text('\u8ba1\u5212\u4e00\u4e2a\u660e\u786e\u4efb\u52a1'), + findsOneWidget, + ); + }); + + testWidgets('development language switch changes the active locale', ( + tester, + ) async { + await tester.pumpWidget(const FocusSeedApp()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('\u6211\u7684')); + await tester.pumpAndSettle(); + + expect(find.text('\u8bed\u8a00'), findsOneWidget); + await tester.tap(find.text('English')); + await tester.pumpAndSettle(); + + expect(find.text('Your space'), findsOneWidget); + expect(find.text('Language'), findsOneWidget); }); }