checkpoint: focus seed 1.0.1 localized dev switch
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 250 KiB |
@@ -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
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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.
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 254 KiB |
@@ -1,52 +1,240 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/ambience.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const seed = Color(0xFF2E7D6B);
|
||||
static const accent = Color(0xFFF4B740);
|
||||
static const paper = Color(0xFFF7F8F5);
|
||||
static const ink = Color(0xFF17211C);
|
||||
static const leaf = Color(0xFF4D8B63);
|
||||
static const mint = Color(0xFFA9E8B5);
|
||||
static const ultramarine = Color(0xFF4E63D8);
|
||||
static const coral = Color(0xFFEA725E);
|
||||
static const violet = Color(0xFF7856D8);
|
||||
static const porcelain = Color(0xFFF7F8F5);
|
||||
|
||||
static ThemeData light() {
|
||||
final scheme = ColorScheme.fromSeed(seedColor: seed, surface: paper);
|
||||
return _base(scheme);
|
||||
}
|
||||
|
||||
static ThemeData dark() {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: Brightness.dark,
|
||||
static ThemeData light(DayPhase phase) {
|
||||
final surface = switch (phase) {
|
||||
DayPhase.dawn => const Color(0xFFF3F8F7),
|
||||
DayPhase.day => porcelain,
|
||||
DayPhase.dusk => const Color(0xFFFBF5F2),
|
||||
DayPhase.night => const Color(0xFFF3F5F3),
|
||||
};
|
||||
final scheme = ColorScheme.fromSeed(seedColor: leaf).copyWith(
|
||||
primary: leaf,
|
||||
onPrimary: Colors.white,
|
||||
secondary: ultramarine,
|
||||
tertiary: coral,
|
||||
surface: surface,
|
||||
onSurface: ink,
|
||||
surfaceContainerLowest: Colors.white.withValues(alpha: 0.82),
|
||||
surfaceContainerLow: Colors.white.withValues(alpha: 0.72),
|
||||
surfaceContainer: const Color(0xFFF0F3EF),
|
||||
surfaceContainerHigh: const Color(0xFFE9EEE9),
|
||||
outline: const Color(0xFFB9C2BA),
|
||||
outlineVariant: const Color(0xFFDDE3DD),
|
||||
);
|
||||
return _base(scheme);
|
||||
return _base(scheme, phase);
|
||||
}
|
||||
|
||||
static ThemeData _base(ColorScheme scheme) {
|
||||
return ThemeData(
|
||||
static ThemeData dark(DayPhase phase) {
|
||||
final surface = switch (phase) {
|
||||
DayPhase.dawn => const Color(0xFF141C1B),
|
||||
DayPhase.day => const Color(0xFF151B18),
|
||||
DayPhase.dusk => const Color(0xFF1A181A),
|
||||
DayPhase.night => const Color(0xFF101614),
|
||||
};
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: mint,
|
||||
brightness: Brightness.dark,
|
||||
).copyWith(
|
||||
primary: mint,
|
||||
onPrimary: ink,
|
||||
secondary: const Color(0xFFB9C2FF),
|
||||
tertiary: const Color(0xFFFFB4A8),
|
||||
surface: surface,
|
||||
onSurface: const Color(0xFFF1F5F1),
|
||||
surfaceContainerLowest: const Color(0xFF0D1210),
|
||||
surfaceContainerLow: const Color(0xFF171E1B),
|
||||
surfaceContainer: const Color(0xFF1C2420),
|
||||
surfaceContainerHigh: const Color(0xFF243029),
|
||||
outline: const Color(0xFF7E8D83),
|
||||
outlineVariant: const Color(0xFF344039),
|
||||
);
|
||||
return _base(scheme, phase);
|
||||
}
|
||||
|
||||
static ThemeData _base(ColorScheme scheme, DayPhase phase) {
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
indicatorColor: scheme.primaryContainer,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(_) => const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
);
|
||||
final textTheme = base.textTheme.copyWith(
|
||||
headlineSmall: base.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.15,
|
||||
),
|
||||
titleLarge: base.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.2,
|
||||
),
|
||||
titleMedium: base.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.25,
|
||||
),
|
||||
bodyLarge: base.textTheme.bodyLarge?.copyWith(height: 1.4),
|
||||
bodyMedium: base.textTheme.bodyMedium?.copyWith(height: 1.4),
|
||||
labelLarge: base.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
return base.copyWith(
|
||||
textTheme: textTheme,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
cardTheme: CardTheme(
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
color: scheme.surfaceContainerHighest.withValues(alpha: 0.55),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
color: scheme.surfaceContainerLowest,
|
||||
shadowColor: ink.withValues(alpha: 0.12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
height: 68,
|
||||
elevation: 0,
|
||||
backgroundColor: scheme.surfaceContainerLowest,
|
||||
indicatorColor: scheme.primary.withValues(alpha: 0.14),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(states) => TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
states.contains(WidgetState.selected)
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: scheme.surfaceContainerLow,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 13,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
chipTheme: base.chipTheme.copyWith(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: scheme.outlineVariant,
|
||||
space: 1,
|
||||
thickness: 1,
|
||||
),
|
||||
dialogTheme: DialogTheme(
|
||||
backgroundColor: scheme.surfaceContainerLowest,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
backgroundColor: scheme.surface,
|
||||
modalBackgroundColor: scheme.surface,
|
||||
showDragHandle: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
),
|
||||
extensions: [FocusSeedVisuals.forPhase(phase, scheme.brightness)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
|
||||
const FocusSeedVisuals({
|
||||
required this.ambientTint,
|
||||
required this.reflection,
|
||||
required this.success,
|
||||
required this.shadow,
|
||||
});
|
||||
|
||||
final Color ambientTint;
|
||||
final Color reflection;
|
||||
final Color success;
|
||||
final Color shadow;
|
||||
|
||||
static FocusSeedVisuals of(BuildContext context) {
|
||||
return Theme.of(context).extension<FocusSeedVisuals>()!;
|
||||
}
|
||||
|
||||
factory FocusSeedVisuals.forPhase(DayPhase phase, Brightness brightness) {
|
||||
final isDark = brightness == Brightness.dark;
|
||||
final ambientTint = switch (phase) {
|
||||
DayPhase.dawn => const Color(0xFFA7D9D2),
|
||||
DayPhase.day => AppTheme.mint,
|
||||
DayPhase.dusk => const Color(0xFFF1B4A8),
|
||||
DayPhase.night => const Color(0xFF9A89D8),
|
||||
};
|
||||
return FocusSeedVisuals(
|
||||
ambientTint: ambientTint.withValues(alpha: isDark ? 0.18 : 0.22),
|
||||
reflection: isDark ? const Color(0xFFBDAAFF) : AppTheme.violet,
|
||||
success: isDark ? AppTheme.mint : AppTheme.leaf,
|
||||
shadow: Colors.black.withValues(alpha: isDark ? 0.32 : 0.10),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
FocusSeedVisuals copyWith({
|
||||
Color? ambientTint,
|
||||
Color? reflection,
|
||||
Color? success,
|
||||
Color? shadow,
|
||||
}) {
|
||||
return FocusSeedVisuals(
|
||||
ambientTint: ambientTint ?? this.ambientTint,
|
||||
reflection: reflection ?? this.reflection,
|
||||
success: success ?? this.success,
|
||||
shadow: shadow ?? this.shadow,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
FocusSeedVisuals lerp(covariant FocusSeedVisuals? other, double t) {
|
||||
if (other == null) {
|
||||
return this;
|
||||
}
|
||||
return FocusSeedVisuals(
|
||||
ambientTint: Color.lerp(ambientTint, other.ambientTint, t)!,
|
||||
reflection: Color.lerp(reflection, other.reflection, t)!,
|
||||
success: Color.lerp(success, other.success, t)!,
|
||||
shadow: Color.lerp(shadow, other.shadow, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
import '../core/ambience.dart';
|
||||
import '../data/app_preferences_store.dart';
|
||||
import '../features/shell/focus_seed_home.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../models/appearance_preference.dart';
|
||||
import '../models/language_preference.dart';
|
||||
import 'app_theme.dart';
|
||||
|
||||
class FocusSeedApp extends StatelessWidget {
|
||||
const FocusSeedApp({super.key});
|
||||
@visibleForTesting
|
||||
Locale focusSeedDefaultLocale({bool isRelease = kReleaseMode}) {
|
||||
return isRelease ? const Locale('en') : const Locale('zh');
|
||||
}
|
||||
|
||||
class FocusSeedApp extends StatefulWidget {
|
||||
const FocusSeedApp({this.locale, super.key});
|
||||
|
||||
final Locale? locale;
|
||||
|
||||
@override
|
||||
State<FocusSeedApp> createState() => _FocusSeedAppState();
|
||||
}
|
||||
|
||||
class _FocusSeedAppState extends State<FocusSeedApp>
|
||||
with WidgetsBindingObserver {
|
||||
final _preferencesStore = AppPreferencesStore();
|
||||
AppearancePreference _appearance = AppearancePreference.system;
|
||||
LanguagePreference _language = LanguagePreference.defaultLocale;
|
||||
DayPhase _dayPhase = dayPhaseFor(DateTime.now());
|
||||
Timer? _phaseTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_loadPreferences();
|
||||
_schedulePhaseRefresh();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_refreshDayPhase();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
final appearance = await _preferencesStore.loadAppearance();
|
||||
final language = await _preferencesStore.loadLanguage();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_appearance = appearance;
|
||||
_language = language;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _setAppearance(AppearancePreference appearance) async {
|
||||
setState(() => _appearance = appearance);
|
||||
await _preferencesStore.saveAppearance(appearance);
|
||||
}
|
||||
|
||||
Future<void> _setLanguage(LanguagePreference language) async {
|
||||
setState(() => _language = language);
|
||||
await _preferencesStore.saveLanguage(language);
|
||||
}
|
||||
|
||||
void _refreshDayPhase() {
|
||||
final phase = dayPhaseFor(DateTime.now());
|
||||
if (phase != _dayPhase && mounted) {
|
||||
setState(() => _dayPhase = phase);
|
||||
}
|
||||
_schedulePhaseRefresh();
|
||||
}
|
||||
|
||||
void _schedulePhaseRefresh() {
|
||||
_phaseTimer?.cancel();
|
||||
final now = DateTime.now();
|
||||
final delay = nextDayPhaseBoundary(now).difference(now);
|
||||
_phaseTimer = Timer(delay, _refreshDayPhase);
|
||||
}
|
||||
|
||||
ThemeMode get _themeMode => switch (_appearance) {
|
||||
AppearancePreference.system => ThemeMode.system,
|
||||
AppearancePreference.light => ThemeMode.light,
|
||||
AppearancePreference.dark => ThemeMode.dark,
|
||||
};
|
||||
|
||||
Locale get _resolvedLocale {
|
||||
return widget.locale ?? _language.locale ?? focusSeedDefaultLocale();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Focus Seed',
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
home: const FocusSeedHome(),
|
||||
theme: AppTheme.light(_dayPhase),
|
||||
darkTheme: AppTheme.dark(_dayPhase),
|
||||
themeMode: _themeMode,
|
||||
themeAnimationDuration: const Duration(milliseconds: 240),
|
||||
locale: _resolvedLocale,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: FocusSeedHome(
|
||||
appearance: _appearance,
|
||||
onAppearanceChanged: _setAppearance,
|
||||
language: _language,
|
||||
showLanguageTools: !kReleaseMode && widget.locale == null,
|
||||
onLanguageChanged: _setLanguage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_phaseTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../core/date_keys.dart';
|
||||
import '../data/focus_seed_store.dart';
|
||||
import '../models/app_state.dart';
|
||||
import '../models/focus_session.dart';
|
||||
import '../models/habit.dart';
|
||||
import '../models/mood.dart';
|
||||
import '../models/reflection_entry.dart';
|
||||
import '../services/analytics_provider.dart';
|
||||
|
||||
enum FocusSeedError { openFailed, saveFailed }
|
||||
|
||||
class FocusSeedController extends ChangeNotifier {
|
||||
FocusSeedController({
|
||||
FocusSeedStore? store,
|
||||
AnalyticsProvider? analytics,
|
||||
List<String> defaultHabitTitles = const [],
|
||||
}) : _store = store ?? FocusSeedStore(),
|
||||
_analytics = analytics ?? const NoopAnalyticsProvider(),
|
||||
_defaultHabitTitles = List.unmodifiable(defaultHabitTitles);
|
||||
|
||||
final FocusSeedStore _store;
|
||||
final AnalyticsProvider _analytics;
|
||||
List<String> _defaultHabitTitles;
|
||||
|
||||
AppState? _state;
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
FocusSeedError? _error;
|
||||
|
||||
AppState? get state => _state;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isSaving => _isSaving;
|
||||
FocusSeedError? get error => _error;
|
||||
|
||||
void updateDefaultHabitTitles(List<String> titles) {
|
||||
_defaultHabitTitles = List.unmodifiable(titles);
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
_state = await _store.load(defaultHabitTitles: _defaultHabitTitles);
|
||||
} on Object {
|
||||
_error = FocusSeedError.openFailed;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> setTodayAnchor(String value) async {
|
||||
final current = _state;
|
||||
final anchor = value.trim();
|
||||
if (current == null || anchor.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
_analytics.track('intention_saved');
|
||||
return _commit(current.copyWith(anchor: anchor, anchorDateKey: todayKey()));
|
||||
}
|
||||
|
||||
Future<bool> toggleHabit(Habit habit) async {
|
||||
final current = _state;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
final today = todayKey();
|
||||
final completedDates = Set<String>.from(habit.completedDates);
|
||||
if (completedDates.contains(today)) {
|
||||
completedDates.remove(today);
|
||||
} else {
|
||||
completedDates.add(today);
|
||||
await HapticFeedback.lightImpact();
|
||||
}
|
||||
final habits = current.habits
|
||||
.map(
|
||||
(item) =>
|
||||
item.id == habit.id
|
||||
? habit.copyWith(completedDates: completedDates)
|
||||
: item,
|
||||
)
|
||||
.toList(growable: false);
|
||||
_analytics.track('habit_toggled');
|
||||
return _commit(current.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<bool> addHabit(String value) async {
|
||||
final current = _state;
|
||||
final title = value.trim();
|
||||
if (current == null || title.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final habit = Habit(
|
||||
id: 'habit-${now.microsecondsSinceEpoch}',
|
||||
title: title,
|
||||
createdAt: now,
|
||||
completedDates: const {},
|
||||
);
|
||||
_analytics.track('habit_created');
|
||||
return _commit(current.copyWith(habits: [...current.habits, habit]));
|
||||
}
|
||||
|
||||
Future<bool> renameHabit(Habit habit, String value) async {
|
||||
final current = _state;
|
||||
final title = value.trim();
|
||||
if (current == null || title.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final habits = current.habits
|
||||
.map((item) => item.id == habit.id ? item.copyWith(title: title) : item)
|
||||
.toList(growable: false);
|
||||
return _commit(current.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<bool> deleteHabit(Habit habit) async {
|
||||
final current = _state;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
final habits = current.habits
|
||||
.where((item) => item.id != habit.id)
|
||||
.toList(growable: false);
|
||||
return _commit(current.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<bool> completeFocusSession(int minutes) async {
|
||||
final current = _state;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final session = FocusSession(
|
||||
id: 'session-${now.microsecondsSinceEpoch}',
|
||||
minutes: minutes,
|
||||
completedAt: now,
|
||||
);
|
||||
_analytics.track('focus_completed', {'minutes': minutes});
|
||||
return _commit(current.copyWith(sessions: [...current.sessions, session]));
|
||||
}
|
||||
|
||||
Future<bool> saveReflection(String value, Mood mood) async {
|
||||
final current = _state;
|
||||
final note = value.trim();
|
||||
if (current == null || note.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final today = todayKey();
|
||||
final now = DateTime.now();
|
||||
final entry = ReflectionEntry(
|
||||
id: 'reflection-${now.microsecondsSinceEpoch}',
|
||||
dateKey: today,
|
||||
note: note,
|
||||
mood: mood,
|
||||
createdAt: now,
|
||||
);
|
||||
final reflections = [
|
||||
...current.reflections.where((item) => item.dateKey != today),
|
||||
entry,
|
||||
];
|
||||
_analytics.track('reflection_saved');
|
||||
return _commit(current.copyWith(reflections: reflections));
|
||||
}
|
||||
|
||||
Future<bool> importState(String raw) async {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return false;
|
||||
}
|
||||
return _commit(AppState.fromJson(decoded));
|
||||
} on Object {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> clearData() async {
|
||||
return _commit(AppState.starter(defaultHabitTitles: _defaultHabitTitles));
|
||||
}
|
||||
|
||||
Future<bool> _commit(AppState nextState) async {
|
||||
final previousState = _state;
|
||||
_state = nextState;
|
||||
_isSaving = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _store.save(nextState);
|
||||
return true;
|
||||
} on Object {
|
||||
_state = previousState;
|
||||
_error = FocusSeedError.saveFailed;
|
||||
return false;
|
||||
} finally {
|
||||
_isSaving = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
enum FocusTimerStatus { idle, running, paused, completed }
|
||||
|
||||
class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
FocusTimerController({
|
||||
required Future<void> Function(int minutes) onCompleted,
|
||||
int initialMinutes = 25,
|
||||
}) : _onCompleted = onCompleted,
|
||||
_selectedMinutes = initialMinutes,
|
||||
_remainingSeconds = initialMinutes * 60 {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
static const presets = [15, 25, 45];
|
||||
|
||||
final Future<void> Function(int minutes) _onCompleted;
|
||||
Timer? _ticker;
|
||||
DateTime? _endsAt;
|
||||
int _selectedMinutes;
|
||||
int _remainingSeconds;
|
||||
FocusTimerStatus _status = FocusTimerStatus.idle;
|
||||
bool _isCompleting = false;
|
||||
|
||||
int get selectedMinutes => _selectedMinutes;
|
||||
int get remainingSeconds => _remainingSeconds;
|
||||
FocusTimerStatus get status => _status;
|
||||
bool get isRunning => _status == FocusTimerStatus.running;
|
||||
bool get isPaused => _status == FocusTimerStatus.paused;
|
||||
bool get isCompleted => _status == FocusTimerStatus.completed;
|
||||
double get progress {
|
||||
final totalSeconds = _selectedMinutes * 60;
|
||||
return totalSeconds == 0
|
||||
? 0
|
||||
: (1 - (_remainingSeconds / totalSeconds)).clamp(0, 1);
|
||||
}
|
||||
|
||||
void selectMinutes(int minutes) {
|
||||
if (isRunning || !presets.contains(minutes)) {
|
||||
return;
|
||||
}
|
||||
_selectedMinutes = minutes;
|
||||
_remainingSeconds = minutes * 60;
|
||||
_status = FocusTimerStatus.idle;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void start() {
|
||||
if (isRunning || _isCompleting) {
|
||||
return;
|
||||
}
|
||||
if (isCompleted || _remainingSeconds <= 0) {
|
||||
_remainingSeconds = _selectedMinutes * 60;
|
||||
}
|
||||
_status = FocusTimerStatus.running;
|
||||
_endsAt = DateTime.now().add(Duration(seconds: _remainingSeconds));
|
||||
_startTicker();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
if (!isRunning) {
|
||||
return;
|
||||
}
|
||||
_refreshRemainingTime();
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_status = FocusTimerStatus.paused;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_remainingSeconds = _selectedMinutes * 60;
|
||||
_status = FocusTimerStatus.idle;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> completeNow() => _complete();
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && isRunning) {
|
||||
_refreshRemainingTime();
|
||||
if (_remainingSeconds > 0) {
|
||||
_startTicker();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startTicker() {
|
||||
_ticker?.cancel();
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
_refreshRemainingTime();
|
||||
if (_remainingSeconds <= 0) {
|
||||
unawaited(_complete());
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _refreshRemainingTime() {
|
||||
final endsAt = _endsAt;
|
||||
if (endsAt == null) {
|
||||
return;
|
||||
}
|
||||
final milliseconds = endsAt.difference(DateTime.now()).inMilliseconds;
|
||||
_remainingSeconds = math.max(0, (milliseconds / 1000).ceil());
|
||||
}
|
||||
|
||||
Future<void> _complete() async {
|
||||
if (_isCompleting || isCompleted) {
|
||||
return;
|
||||
}
|
||||
_isCompleting = true;
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_remainingSeconds = 0;
|
||||
_status = FocusTimerStatus.completed;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _onCompleted(_selectedMinutes);
|
||||
} finally {
|
||||
_isCompleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -11,25 +11,6 @@ String dateKey(DateTime date) {
|
||||
'${local.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String readableToday() {
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
final now = DateTime.now();
|
||||
return '${months[now.month - 1]} ${now.day}, ${now.year}';
|
||||
}
|
||||
|
||||
List<String> lastSevenDateKeys() {
|
||||
final now = DateTime.now();
|
||||
return List.generate(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,16 @@ import '../models/app_state.dart';
|
||||
class FocusSeedStore {
|
||||
static const _storageKey = 'focus_seed_state_v1';
|
||||
|
||||
Future<AppState> load() async {
|
||||
Future<AppState> load({List<String>? defaultHabitTitles}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_storageKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return AppState.starter();
|
||||
return AppState.starter(defaultHabitTitles: defaultHabitTitles);
|
||||
}
|
||||
try {
|
||||
return AppState.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
} on Object {
|
||||
return AppState.starter();
|
||||
return AppState.starter(defaultHabitTitles: defaultHabitTitles);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../application/focus_seed_controller.dart';
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../today/widgets/compact_habit_row.dart';
|
||||
|
||||
Future<void> showHabitManagerSheet(
|
||||
BuildContext context,
|
||||
FocusSeedController controller,
|
||||
) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder:
|
||||
(context) => FractionallySizedBox(
|
||||
heightFactor: 0.86,
|
||||
child: AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
final state = controller.state;
|
||||
if (state == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final l10n = context.l10n;
|
||||
final today = todayKey();
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.habitsTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Text(
|
||||
l10n.habitsSubtitle,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.addHabit,
|
||||
onPressed:
|
||||
() =>
|
||||
showHabitDialog(context, controller.addHabit),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child:
|
||||
state.habits.isEmpty
|
||||
? _EmptyHabits(
|
||||
onAdd:
|
||||
() => showHabitDialog(
|
||||
context,
|
||||
controller.addHabit,
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
|
||||
itemCount: state.habits.length,
|
||||
separatorBuilder: (_, _) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final habit = state.habits[index];
|
||||
return CompactHabitRow(
|
||||
habit: habit,
|
||||
dateKey: today,
|
||||
onToggle: () => controller.toggleHabit(habit),
|
||||
onEdit:
|
||||
() => showHabitDialog(
|
||||
context,
|
||||
(value) => controller.renameHabit(
|
||||
habit,
|
||||
value,
|
||||
),
|
||||
initialValue: habit.title,
|
||||
),
|
||||
onDelete:
|
||||
() => confirmDeleteHabit(
|
||||
context,
|
||||
() => controller.deleteHabit(habit),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _EmptyHabits extends StatelessWidget {
|
||||
const _EmptyHabits({required this.onAdd});
|
||||
|
||||
final VoidCallback onAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.eco_outlined,
|
||||
size: 40,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.emptyHabitsTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(context.l10n.addHabit),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../models/app_state.dart';
|
||||
import '../../models/habit.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../../widgets/empty_state.dart';
|
||||
import '../../widgets/habit_row.dart';
|
||||
|
||||
class HabitsScreen extends StatelessWidget {
|
||||
const HabitsScreen({
|
||||
required this.state,
|
||||
required this.onAddHabit,
|
||||
required this.onRenameHabit,
|
||||
required this.onDeleteHabit,
|
||||
required this.onToggleHabit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
final ValueChanged<String> onAddHabit;
|
||||
final void Function(Habit habit, String title) onRenameHabit;
|
||||
final ValueChanged<Habit> onDeleteHabit;
|
||||
final ValueChanged<Habit> onToggleHabit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final today = todayKey();
|
||||
return AppScaffold(
|
||||
title: 'Habits',
|
||||
subtitle: 'Keep the list small and repeatable.',
|
||||
action: IconButton.filledTonal(
|
||||
tooltip: 'Add habit',
|
||||
onPressed: () => showHabitDialog(context, onAddHabit),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
children: [
|
||||
if (state.habits.isEmpty)
|
||||
EmptyState(
|
||||
icon: Icons.checklist_outlined,
|
||||
title: 'No habits yet',
|
||||
action: 'Add habit',
|
||||
onAction: () => showHabitDialog(context, onAddHabit),
|
||||
)
|
||||
else
|
||||
...state.habits.map(
|
||||
(habit) => HabitRow(
|
||||
habit: habit,
|
||||
dateKey: today,
|
||||
onToggle: () => onToggleHabit(habit),
|
||||
onEdit:
|
||||
() => showHabitDialog(
|
||||
context,
|
||||
(title) => onRenameHabit(habit, title),
|
||||
initialValue: habit.title,
|
||||
),
|
||||
onDelete:
|
||||
() =>
|
||||
confirmDeleteHabit(context, () => onDeleteHabit(habit)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,269 +1,184 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../data/focus_seed_store.dart';
|
||||
import '../../models/app_state.dart';
|
||||
import '../../models/focus_session.dart';
|
||||
import '../../models/habit.dart';
|
||||
import '../../models/mood.dart';
|
||||
import '../../models/reflection_entry.dart';
|
||||
import '../../services/analytics_provider.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../../application/focus_seed_controller.dart';
|
||||
import '../../application/focus_timer_controller.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../models/appearance_preference.dart';
|
||||
import '../../models/language_preference.dart';
|
||||
import '../../widgets/loading_view.dart';
|
||||
import '../habits/habits_screen.dart';
|
||||
import '../home/home_screen.dart';
|
||||
import '../reflection/reflection_screen.dart';
|
||||
import '../settings/settings_screen.dart';
|
||||
import '../timer/timer_screen.dart';
|
||||
import '../journey/journey_screen.dart';
|
||||
import '../me/me_screen.dart';
|
||||
import '../today/today_screen.dart';
|
||||
|
||||
class FocusSeedHome extends StatefulWidget {
|
||||
const FocusSeedHome({super.key});
|
||||
const FocusSeedHome({
|
||||
required this.appearance,
|
||||
required this.onAppearanceChanged,
|
||||
required this.language,
|
||||
required this.showLanguageTools,
|
||||
required this.onLanguageChanged,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppearancePreference appearance;
|
||||
final ValueChanged<AppearancePreference> onAppearanceChanged;
|
||||
final LanguagePreference language;
|
||||
final bool showLanguageTools;
|
||||
final ValueChanged<LanguagePreference> onLanguageChanged;
|
||||
|
||||
@override
|
||||
State<FocusSeedHome> createState() => _FocusSeedHomeState();
|
||||
}
|
||||
|
||||
class _FocusSeedHomeState extends State<FocusSeedHome> {
|
||||
final _store = FocusSeedStore();
|
||||
final _analytics = const NoopAnalyticsProvider();
|
||||
AppState? _state;
|
||||
FocusSeedController? _controller;
|
||||
FocusTimerController? _timerController;
|
||||
int _tabIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final state = await _store.load();
|
||||
if (!mounted) {
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) {
|
||||
return;
|
||||
}
|
||||
setState(() => _state = state);
|
||||
_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);
|
||||
await _store.save(state);
|
||||
},
|
||||
);
|
||||
_controller!.load();
|
||||
}
|
||||
|
||||
@override
|
||||
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(
|
||||
body: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child:
|
||||
state == null
|
||||
? const LoadingView()
|
||||
: SafeArea(
|
||||
child: IndexedStack(
|
||||
index: _tabIndex,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
HomeScreen(
|
||||
state: state,
|
||||
onAnchorChanged:
|
||||
(anchor) => _setStateAndSave(
|
||||
state.copyWith(anchor: anchor.trim()),
|
||||
const Icon(Icons.error_outline_rounded, size: 42),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_messageForError(l10n, controller.error) ??
|
||||
l10n.couldNotOpenApp,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
onOpenTimer: () => setState(() => _tabIndex = 1),
|
||||
onToggleHabit: _toggleHabit,
|
||||
onAddHabit: _addHabit,
|
||||
),
|
||||
TimerScreen(
|
||||
onComplete: _completeFocusSession,
|
||||
onReflect: () => setState(() => _tabIndex = 3),
|
||||
),
|
||||
HabitsScreen(
|
||||
state: state,
|
||||
onAddHabit: _addHabit,
|
||||
onRenameHabit: _renameHabit,
|
||||
onDeleteHabit: _deleteHabit,
|
||||
onToggleHabit: _toggleHabit,
|
||||
),
|
||||
ReflectionScreen(state: state, onSave: _saveReflection),
|
||||
SettingsScreen(
|
||||
state: state,
|
||||
onImport: _importState,
|
||||
onClear: _clearData,
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: controller.load,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(l10n.tryAgain),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar:
|
||||
state == null
|
||||
? null
|
||||
: NavigationBar(
|
||||
),
|
||||
);
|
||||
}
|
||||
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,
|
||||
onDestinationSelected: (index) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _tabIndex = index);
|
||||
},
|
||||
destinations: const [
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Today',
|
||||
icon: const Icon(Icons.eco_outlined),
|
||||
selectedIcon: const Icon(Icons.eco_rounded),
|
||||
label: context.l10n.navToday,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.timer_outlined),
|
||||
selectedIcon: Icon(Icons.timer),
|
||||
label: 'Timer',
|
||||
icon: const Icon(Icons.landscape_outlined),
|
||||
selectedIcon: const Icon(Icons.landscape_rounded),
|
||||
label: context.l10n.navJourney,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.check_circle_outline),
|
||||
selectedIcon: Icon(Icons.check_circle),
|
||||
label: 'Habits',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.edit_note_outlined),
|
||||
selectedIcon: Icon(Icons.edit_note),
|
||||
label: 'Reflect',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
icon: const Icon(Icons.person_outline_rounded),
|
||||
selectedIcon: const Icon(Icons.person_rounded),
|
||||
label: context.l10n.navMe,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleHabit(Habit habit) async {
|
||||
final state = _state;
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
final today = todayKey();
|
||||
final completedDates = Set<String>.from(habit.completedDates);
|
||||
if (completedDates.contains(today)) {
|
||||
completedDates.remove(today);
|
||||
} else {
|
||||
completedDates.add(today);
|
||||
await HapticFeedback.lightImpact();
|
||||
}
|
||||
final habits =
|
||||
state.habits
|
||||
.map(
|
||||
(item) =>
|
||||
item.id == habit.id
|
||||
? habit.copyWith(completedDates: completedDates)
|
||||
: item,
|
||||
)
|
||||
.toList();
|
||||
_analytics.track('habit_toggled');
|
||||
await _setStateAndSave(state.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<void> _addHabit(String title) async {
|
||||
final state = _state;
|
||||
final clean = title.trim();
|
||||
if (state == null || clean.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final habit = Habit(
|
||||
id: 'habit-${DateTime.now().microsecondsSinceEpoch}',
|
||||
title: clean,
|
||||
createdAt: DateTime.now(),
|
||||
completedDates: {},
|
||||
);
|
||||
_analytics.track('habit_created');
|
||||
await _setStateAndSave(state.copyWith(habits: [...state.habits, habit]));
|
||||
}
|
||||
|
||||
Future<void> _renameHabit(Habit habit, String title) async {
|
||||
final state = _state;
|
||||
final clean = title.trim();
|
||||
if (state == null || clean.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final habits =
|
||||
state.habits
|
||||
.map(
|
||||
(item) =>
|
||||
item.id == habit.id ? item.copyWith(title: clean) : item,
|
||||
)
|
||||
.toList();
|
||||
await _setStateAndSave(state.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<void> _deleteHabit(Habit habit) async {
|
||||
final state = _state;
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
final habits = state.habits.where((item) => item.id != habit.id).toList();
|
||||
await _setStateAndSave(state.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<void> _completeFocusSession(int minutes) async {
|
||||
final state = _state;
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
final session = FocusSession(
|
||||
id: 'session-${DateTime.now().microsecondsSinceEpoch}',
|
||||
minutes: minutes,
|
||||
completedAt: DateTime.now(),
|
||||
);
|
||||
_analytics.track('focus_completed', {'minutes': minutes});
|
||||
await _setStateAndSave(
|
||||
state.copyWith(sessions: [...state.sessions, session]),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveReflection(String note, Mood mood) async {
|
||||
final state = _state;
|
||||
final clean = note.trim();
|
||||
if (state == null || clean.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final today = todayKey();
|
||||
final entry = ReflectionEntry(
|
||||
id: 'reflection-${DateTime.now().microsecondsSinceEpoch}',
|
||||
dateKey: today,
|
||||
note: clean,
|
||||
mood: mood,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
final reflections = [
|
||||
...state.reflections.where((item) => item.dateKey != today),
|
||||
entry,
|
||||
];
|
||||
_analytics.track('reflection_saved');
|
||||
await _setStateAndSave(state.copyWith(reflections: reflections));
|
||||
if (mounted) {
|
||||
showSnack(context, 'Reflection saved');
|
||||
@override
|
||||
void dispose() {
|
||||
_timerController?.dispose();
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importState(String raw) async {
|
||||
try {
|
||||
final imported = AppState.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
await _setStateAndSave(imported);
|
||||
if (mounted) {
|
||||
showSnack(context, 'Data imported');
|
||||
}
|
||||
} on Object {
|
||||
if (mounted) {
|
||||
showSnack(context, 'Import failed. Check the JSON and try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearData() async {
|
||||
await _store.clear();
|
||||
await _setStateAndSave(AppState.starter());
|
||||
if (mounted) {
|
||||
showSnack(context, 'Local data cleared');
|
||||
}
|
||||
}
|
||||
String? _messageForError(AppLocalizations l10n, FocusSeedError? error) {
|
||||
return switch (error) {
|
||||
FocusSeedError.openFailed => l10n.openFailed,
|
||||
FocusSeedError.saveFailed => l10n.saveFailed,
|
||||
null => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
import '../../widgets/seed_card.dart';
|
||||
|
||||
class TimerScreen extends StatefulWidget {
|
||||
const TimerScreen({
|
||||
required this.onComplete,
|
||||
required this.onReflect,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ValueChanged<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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 => '写一句复盘';
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -5,26 +5,34 @@ import 'reflection_entry.dart';
|
||||
class AppState {
|
||||
const AppState({
|
||||
required this.anchor,
|
||||
required this.anchorDateKey,
|
||||
required this.habits,
|
||||
required this.sessions,
|
||||
required this.reflections,
|
||||
});
|
||||
|
||||
final String anchor;
|
||||
final String anchorDateKey;
|
||||
final List<Habit> habits;
|
||||
final List<FocusSession> sessions;
|
||||
final List<ReflectionEntry> reflections;
|
||||
|
||||
bool get isFreshStart => habits.isEmpty && sessions.isEmpty && anchor.isEmpty;
|
||||
|
||||
String activeAnchor(String dateKey) {
|
||||
return anchorDateKey == dateKey ? anchor : '';
|
||||
}
|
||||
|
||||
AppState copyWith({
|
||||
String? anchor,
|
||||
String? anchorDateKey,
|
||||
List<Habit>? habits,
|
||||
List<FocusSession>? sessions,
|
||||
List<ReflectionEntry>? reflections,
|
||||
}) {
|
||||
return AppState(
|
||||
anchor: anchor ?? this.anchor,
|
||||
anchorDateKey: anchorDateKey ?? this.anchorDateKey,
|
||||
habits: habits ?? this.habits,
|
||||
sessions: sessions ?? this.sessions,
|
||||
reflections: reflections ?? this.reflections,
|
||||
@@ -33,8 +41,9 @@ class AppState {
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'version': 1,
|
||||
'version': 2,
|
||||
'anchor': anchor,
|
||||
'anchorDateKey': anchorDateKey,
|
||||
'habits': habits.map((habit) => habit.toJson()).toList(),
|
||||
'sessions': sessions.map((session) => session.toJson()).toList(),
|
||||
'reflections':
|
||||
@@ -43,8 +52,12 @@ class AppState {
|
||||
}
|
||||
|
||||
static AppState fromJson(Map<String, dynamic> json) {
|
||||
final anchor = (json['anchor'] as String?) ?? '';
|
||||
return AppState(
|
||||
anchor: (json['anchor'] as String?) ?? '',
|
||||
anchor: anchor,
|
||||
anchorDateKey:
|
||||
(json['anchorDateKey'] as String?) ??
|
||||
(anchor.isEmpty ? '' : _currentDateKey()),
|
||||
habits: _jsonObjects(json['habits']).map(Habit.fromJson).toList(),
|
||||
sessions:
|
||||
_jsonObjects(json['sessions']).map(FocusSession.fromJson).toList(),
|
||||
@@ -62,26 +75,35 @@ class AppState {
|
||||
return value.whereType<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
static AppState starter() {
|
||||
static AppState starter({List<String>? defaultHabitTitles}) {
|
||||
final now = DateTime.now();
|
||||
final titles =
|
||||
defaultHabitTitles?.length == 3
|
||||
? defaultHabitTitles!
|
||||
: const [
|
||||
'Plan one clear task',
|
||||
'Focus for 25 minutes',
|
||||
'Write one-line reflection',
|
||||
];
|
||||
return AppState(
|
||||
anchor: '',
|
||||
anchorDateKey: _currentDateKey(now),
|
||||
habits: [
|
||||
Habit(
|
||||
id: 'habit-${now.microsecondsSinceEpoch}-1',
|
||||
title: 'Plan one clear task',
|
||||
title: titles[0],
|
||||
createdAt: now,
|
||||
completedDates: {},
|
||||
),
|
||||
Habit(
|
||||
id: 'habit-${now.microsecondsSinceEpoch}-2',
|
||||
title: 'Focus for 25 minutes',
|
||||
title: titles[1],
|
||||
createdAt: now,
|
||||
completedDates: {},
|
||||
),
|
||||
Habit(
|
||||
id: 'habit-${now.microsecondsSinceEpoch}-3',
|
||||
title: 'Write a one-line reflection',
|
||||
title: titles[2],
|
||||
createdAt: now,
|
||||
completedDates: {},
|
||||
),
|
||||
@@ -90,4 +112,11 @@ class AppState {
|
||||
reflections: const [],
|
||||
);
|
||||
}
|
||||
|
||||
static String _currentDateKey([DateTime? value]) {
|
||||
final date = value ?? DateTime.now();
|
||||
return '${date.year.toString().padLeft(4, '0')}-'
|
||||
'${date.month.toString().padLeft(2, '0')}-'
|
||||
'${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
enum AppearancePreference { system, light, dark }
|
||||
|
||||
AppearancePreference appearancePreferenceFromName(String? name) {
|
||||
return AppearancePreference.values.firstWhere(
|
||||
(preference) => preference.name == name,
|
||||
orElse: () => AppearancePreference.system,
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum Mood {
|
||||
calm('Calm', Icons.spa_outlined),
|
||||
clear('Clear', Icons.wb_sunny_outlined),
|
||||
proud('Proud', Icons.emoji_events_outlined),
|
||||
tired('Tired', Icons.bedtime_outlined);
|
||||
calm(Icons.spa_outlined),
|
||||
clear(Icons.wb_sunny_outlined),
|
||||
proud(Icons.emoji_events_outlined),
|
||||
tired(Icons.bedtime_outlined);
|
||||
|
||||
const Mood(this.label, this.icon);
|
||||
const Mood(this.icon);
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../core/json_utils.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/app_state.dart';
|
||||
|
||||
Future<void> showHabitDialog(
|
||||
@@ -9,21 +10,26 @@ Future<void> showHabitDialog(
|
||||
ValueChanged<String> onSubmit, {
|
||||
String initialValue = '',
|
||||
}) async {
|
||||
final controller = TextEditingController(text: initialValue);
|
||||
var value = initialValue;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(initialValue.isEmpty ? 'Add habit' : 'Rename habit'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
title: Text(
|
||||
initialValue.isEmpty
|
||||
? context.l10n.addHabitTitle
|
||||
: context.l10n.renameHabitTitle,
|
||||
),
|
||||
content: TextFormField(
|
||||
initialValue: initialValue,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Example: Read 10 pages',
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: context.l10n.habitHint,
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
onChanged: (nextValue) => value = nextValue,
|
||||
onFieldSubmitted: (value) {
|
||||
onSubmit(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
@@ -31,19 +37,63 @@ Future<void> showHabitDialog(
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onSubmit(controller.text);
|
||||
onSubmit(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
child: Text(context.l10n.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showIntentionDialog(
|
||||
BuildContext context,
|
||||
ValueChanged<String> onSubmit, {
|
||||
String initialValue = '',
|
||||
}) async {
|
||||
var value = initialValue;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(
|
||||
initialValue.isEmpty
|
||||
? context.l10n.setIntentionTitle
|
||||
: context.l10n.editIntentionTitle,
|
||||
),
|
||||
content: TextFormField(
|
||||
initialValue: initialValue,
|
||||
autofocus: true,
|
||||
maxLength: 90,
|
||||
maxLines: 3,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(hintText: context.l10n.intentionHint),
|
||||
onChanged: (nextValue) => value = nextValue,
|
||||
onFieldSubmitted: (value) {
|
||||
onSubmit(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onSubmit(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Future<void> confirmDeleteHabit(
|
||||
@@ -54,21 +104,19 @@ Future<void> confirmDeleteHabit(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Delete habit?'),
|
||||
content: const Text(
|
||||
'Past completion records for this habit will be removed.',
|
||||
),
|
||||
title: Text(context.l10n.deleteHabitTitle),
|
||||
content: Text(context.l10n.deleteHabitBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
child: Text(context.l10n.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -76,79 +124,78 @@ Future<void> confirmDeleteHabit(
|
||||
}
|
||||
|
||||
Future<void> showExportDialog(BuildContext context, AppState state) async {
|
||||
final controller = TextEditingController(text: prettyJson(state.toJson()));
|
||||
final scaffoldContext = context;
|
||||
final export = prettyJson(state.toJson());
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Export JSON'),
|
||||
title: Text(context.l10n.exportJson),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
minLines: 8,
|
||||
maxLines: 12,
|
||||
readOnly: true,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
export,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(context.l10n.close),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: controller.text));
|
||||
final message = context.l10n.exportCopied;
|
||||
await Clipboard.setData(ClipboardData(text: export));
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
showSnack(context, 'Export copied');
|
||||
showSnack(scaffoldContext, message);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy'),
|
||||
label: Text(context.l10n.copy),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Future<void> showImportDialog(
|
||||
BuildContext context,
|
||||
ValueChanged<String> onImport,
|
||||
) async {
|
||||
final controller = TextEditingController();
|
||||
var value = '';
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Import JSON'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
title: Text(context.l10n.importJson),
|
||||
content: TextFormField(
|
||||
minLines: 8,
|
||||
maxLines: 12,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Paste Focus Seed JSON export',
|
||||
onChanged: (nextValue) => value = nextValue,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: context.l10n.importHint,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onImport(controller.text);
|
||||
onImport(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Import'),
|
||||
child: Text(context.l10n.import),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Future<void> confirmClearData(
|
||||
@@ -159,21 +206,19 @@ Future<void> confirmClearData(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Clear local data?'),
|
||||
content: const Text(
|
||||
'This resets habits, focus sessions, and reflections on this device.',
|
||||
),
|
||||
title: Text(context.l10n.clearDataTitle),
|
||||
content: Text(context.l10n.clearDataBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
child: Text(context.l10n.clear),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -189,23 +234,18 @@ void showPrivacySheet(BuildContext context) {
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: const [
|
||||
children: [
|
||||
Text(
|
||||
'Privacy',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
|
||||
context.l10n.privacyTitle,
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,11 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -96,6 +101,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.19.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -30,10 +30,13 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
intl: any
|
||||
shared_preferences: ^2.5.3
|
||||
|
||||
dev_dependencies:
|
||||
@@ -52,12 +55,16 @@ dev_dependencies:
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
generate: true
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/images/growth/
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_seed/src/app/focus_seed_app.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Focus Seed opens to the today screen', (tester) async {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('locale policy defaults to English for release and Chinese for dev', () {
|
||||
expect(focusSeedDefaultLocale(isRelease: true), const Locale('en'));
|
||||
expect(focusSeedDefaultLocale(), const Locale('zh'));
|
||||
});
|
||||
|
||||
testWidgets('Today guides the user from intention to focus', (tester) async {
|
||||
await tester.pumpWidget(const FocusSeedApp(locale: Locale('en')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Focus Seed'), findsOneWidget);
|
||||
expect(find.text("TODAY'S INTENTION"), findsOneWidget);
|
||||
expect(find.text("Set today's intention"), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Choose one outcome'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byType(TextField),
|
||||
'Finish the launch checklist',
|
||||
);
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Finish the launch checklist'), findsOneWidget);
|
||||
expect(find.text('Start 25 min'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('bottom navigation exposes Journey and Me', (tester) async {
|
||||
await tester.pumpWidget(const FocusSeedApp(locale: Locale('en')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Journey'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text("Today's journey"), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Me'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Your space'), findsOneWidget);
|
||||
expect(find.text('Appearance'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Today remains usable at narrow width and 200 percent text', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 2;
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
tester.platformDispatcher.clearTextScaleFactorTestValue();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(const FocusSeedApp(locale: Locale('en')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Focus Seed'), findsOneWidget);
|
||||
expect(find.byType(Scrollable), findsWidgets);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('development default uses concise Chinese copy', (tester) async {
|
||||
await tester.pumpWidget(const FocusSeedApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Focus Seed'), findsOneWidget);
|
||||
expect(find.text('Today anchor'), findsOneWidget);
|
||||
expect(find.text('Start focus'), findsOneWidget);
|
||||
expect(find.text('\u4eca\u65e5\u76ee\u6807'), findsOneWidget);
|
||||
expect(find.text('\u8bbe\u7f6e\u4eca\u65e5\u76ee\u6807'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('\u8ba1\u5212\u4e00\u4e2a\u660e\u786e\u4efb\u52a1'),
|
||||
180,
|
||||
);
|
||||
expect(
|
||||
find.text('\u8ba1\u5212\u4e00\u4e2a\u660e\u786e\u4efb\u52a1'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('development language switch changes the active locale', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const FocusSeedApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('\u6211\u7684'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('\u8bed\u8a00'), findsOneWidget);
|
||||
await tester.tap(find.text('English'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Your space'), findsOneWidget);
|
||||
expect(find.text('Language'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||