checkpoint: focus seed 1.0.1 localized dev switch

This commit is contained in:
2026-06-11 23:35:49 +08:00
parent c51abdfb73
commit d06dc35ee6
72 changed files with 6155 additions and 1390 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

+35
View File
@@ -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
+57
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+117
View File
@@ -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.
+6
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

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