Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8afa47952a | ||
|
|
2e0bf3c784 | ||
|
|
e7e4c78865 | ||
|
|
185492395e | ||
|
|
33e96072c9 | ||
|
|
eb8b209c0c | ||
|
|
b287a56731 | ||
|
|
b88b947e66 | ||
|
|
9ba681e3c7 | ||
|
|
d06dc35ee6 |
@@ -1,14 +1,16 @@
|
||||
# Focus Seed
|
||||
|
||||
Focus Seed is a private, offline-first focus timer, habit tracker, and daily reflection app built with Flutter.
|
||||
Focus Seed is the current working name for a Flutter product evolving from a lightweight daily action loop into a personal growth and behavior intelligence system.
|
||||
|
||||
## V1 Product Loop
|
||||
Current implementation starts with local-first focus sessions, habits, reflection, records, and privacy controls. Product direction, PRD, and roadmap are governed by `docs/product-current-baseline.md`.
|
||||
|
||||
1. Choose one anchor task for today.
|
||||
2. Complete a focused session.
|
||||
3. Check off small habits.
|
||||
4. Save a one-minute reflection.
|
||||
5. Review the seven-day pulse.
|
||||
## Current MVP Loop
|
||||
|
||||
1. Choose or continue one meaningful action.
|
||||
2. Complete a focused/action session.
|
||||
3. Capture a short record.
|
||||
4. Review accumulated records.
|
||||
5. Grow toward calendar, goals, tags, trends, and user insight over time.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -26,7 +28,7 @@ See `launch/architecture.md` for the complete responsibility map.
|
||||
|
||||
## Privacy
|
||||
|
||||
Version 1.0.0 has no account system, server, advertising SDK, analytics SDK, billing SDK, or sensitive Android permissions. User data remains on the device unless the user explicitly exports it.
|
||||
The current implementation has no account system, server, advertising SDK, analytics SDK, billing SDK, or sensitive Android permissions. This is the current trust and launch posture, not a permanent product ceiling.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -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,59 @@
|
||||
# Focus Seed 1.0.1 Architecture
|
||||
|
||||
Status: historical reference. The current architecture and navigation contract lives in `../../launch/architecture.md` and `../product-current-baseline.md`.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Focus Seed Visual System v1.0.2
|
||||
|
||||
Status: visual direction reference. Current product scope is defined in `../product-current-baseline.md`.
|
||||
|
||||
## Product Thesis
|
||||
|
||||
Focus Seed is an offline-first personal focus companion. It helps users choose one important thing, complete focused sessions, keep daily habits, write a short reflection, and review visible progress.
|
||||
|
||||
The product should not be plant-only. "Seed" means intention, momentum, craft, discipline, and progress. Plant visuals are allowed only as one mature motif, mostly for growth, empty states, or reward moments.
|
||||
|
||||
## Recommended Direction
|
||||
|
||||
Use a hybrid direction:
|
||||
|
||||
- Base: Editorial Focus Companion.
|
||||
- Structure: Soft Bento Productivity.
|
||||
- Focus mode: Precision Dark as an optional immersive screen language.
|
||||
- Warmth: Light editorial or tactile illustration for empty states, rewards, and growth.
|
||||
|
||||
Working name: Calm Modular Editorial.
|
||||
|
||||
This direction is global-friendly, feasible in Flutter, serious enough for productivity users, warm enough for retention, and flexible enough for future subscription features.
|
||||
|
||||
## Primary Product Loop
|
||||
|
||||
1. Pick today's main focus.
|
||||
2. Start or continue a focus session.
|
||||
3. Complete daily habits.
|
||||
4. Write one short reflection.
|
||||
5. Review growth and adjust tomorrow.
|
||||
|
||||
## Target App Structure
|
||||
|
||||
Current V1 primary navigation:
|
||||
|
||||
- Today: daily action hub.
|
||||
- Focus: immersive timer and active session.
|
||||
- Records: focus sessions and reflection history.
|
||||
- Me: preferences, privacy, and local data controls.
|
||||
|
||||
Habits and Growth are not current primary tabs. They can be promoted later only when habit management, trend review, and detail pages are real product surfaces rather than thin wrappers.
|
||||
|
||||
## Screen Hierarchy
|
||||
|
||||
| Screen | User goal | First visible content | Primary action | Secondary content | Scroll behavior |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Today | Know what to do now | Date, greeting, main focus | Start focus or set focus | Habit summary, reflection prompt, small growth cue | Natural vertical scroll; P0 remains near top |
|
||||
| Focus | Stay in one session | Current task and timer | Pause/complete/start | Duration, mode, next break, session stats | Minimal scroll; controls stable |
|
||||
| Records | Review saved work | Focus/reflection history switch | Open Today when empty | Sessions, notes, simple trends | Timeline/list; detail pages are V2 |
|
||||
| Me | Adjust app and privacy | Local data summary | Change preference | Privacy, clear data, language | Compact grouped list |
|
||||
|
||||
## Visual System
|
||||
|
||||
Palette:
|
||||
|
||||
- Brand: deep ink for identity, headings, and logo text. Do not use brand as a catch-all accent.
|
||||
- Action: blue for primary buttons, navigation selection, and forward movement.
|
||||
- Focus: blue family for timer and active-session controls.
|
||||
- Habit: amber or warm accent for small repeatable actions.
|
||||
- Reflection: violet for notes, review, and insight.
|
||||
- Success: green for completion, checkmarks, and finished progress.
|
||||
- Privacy/local: green-teal for on-device trust indicators only.
|
||||
- Warm neutral surfaces instead of pure white where appropriate.
|
||||
- Warning/destructive/disabled/offline must remain separate semantic colors.
|
||||
- Dark mode must be designed, not inverted.
|
||||
|
||||
Typography:
|
||||
|
||||
- Use highly readable system UI for body and dense data.
|
||||
- Optional expressive display style only for brand/page title, not dense content.
|
||||
- Timer and stats need tabular, stable numeric rendering.
|
||||
|
||||
Spacing and surfaces:
|
||||
|
||||
- Use an 8 px base rhythm with 4 px fine adjustments.
|
||||
- Cards should have roles: hero, action, stat, list container, warning, reward.
|
||||
- Avoid stacking identical large rounded cards.
|
||||
- Prefer compact rows for dense data.
|
||||
|
||||
Motifs:
|
||||
|
||||
- Focus ring, anchor point, path, window/light, notebook, timeline, gentle growth.
|
||||
- Use illustration in empty states, onboarding, growth, and rewards.
|
||||
- Do not decorate every card.
|
||||
|
||||
Asset ambition:
|
||||
|
||||
- Be more expressive with the logo, app icon, key illustration set, reward visuals, and store screenshots. These are allowed to carry stronger identity than the everyday UI.
|
||||
- Do not rely only on the plant metaphor. Explore sharper symbols such as focus rings, marked paths, light windows, paper cuts, modular tiles, timeline traces, and crafted seed/anchor hybrids.
|
||||
- Navigation and utility icons should stay readable and consistent, but they may use a custom symbolic grammar after the structure passes.
|
||||
- Illustrations should feel complete and production-worthy at their real display size. Avoid rough placeholder line art in polished previews.
|
||||
- Dense task surfaces stay restrained; onboarding, empty states, milestones, completion moments, and launch assets can be bolder.
|
||||
|
||||
## Interaction And Motion
|
||||
|
||||
Motion budget:
|
||||
|
||||
- 80% functional feedback: press, select, save, toggle.
|
||||
- 15% spatial motion: tab/page/sheet/list transitions.
|
||||
- 5% reward: session complete, streak, milestone.
|
||||
|
||||
Motion ambition:
|
||||
|
||||
- Reward moments may be more memorable than the base UI: focus ring bloom, path lighting, notebook mark, seed-to-signal transition, or timeline pulse.
|
||||
- Keep bold motion rare, interruptible, and non-blocking. It should celebrate completion or clarify state, not decorate every tap.
|
||||
- Use Flutter-safe primitives first. Escalate to Rive/Lottie only for a signature reward or onboarding moment with a static fallback and reduced-motion behavior.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Tap feedback within 100 ms.
|
||||
- Page transitions preserve direction.
|
||||
- Timer updates must not rebuild entire pages.
|
||||
- Completion should be rewarding but non-blocking.
|
||||
- Reduced motion must disable decorative effects.
|
||||
- Bottom tabs preserve state and scroll position.
|
||||
|
||||
## Component System
|
||||
|
||||
Create or consolidate:
|
||||
|
||||
- AppScaffold and AppTopBar.
|
||||
- AppNavigation.
|
||||
- AppButton variants.
|
||||
- AppCard role variants.
|
||||
- SectionHeader.
|
||||
- CompactStatCard.
|
||||
- DenseListRow.
|
||||
- HabitRow.
|
||||
- TimelineItem.
|
||||
- EditableTextCard.
|
||||
- EmptyState and ErrorState.
|
||||
- OfflineBadge and SaveStateIndicator.
|
||||
- AnimatedNumber, AnimatedProgressBar, AnimatedProgressRing.
|
||||
- RewardMoment.
|
||||
|
||||
## Content Rules
|
||||
|
||||
- English-first for release; Chinese supported for development/testing.
|
||||
- Visible text must be user-facing, not developer-facing.
|
||||
- Avoid words like JSON, debug, select result, internal, export/import unless a real user feature needs them.
|
||||
- Empty states should guide one action.
|
||||
- Destructive actions must state impact clearly.
|
||||
|
||||
## Flutter Execution Plan
|
||||
|
||||
1. Create design, component, and motion foundations.
|
||||
2. Prototype Today and Focus first; review screenshots and interaction.
|
||||
3. Keep Records and Me honest until Habits/Growth have enough product depth to become primary tabs.
|
||||
4. Add detail pages only when they have data writes, navigation targets, and clear user outcomes.
|
||||
5. Keep Settings compact and user-facing; avoid developer-tool language.
|
||||
6. Add empty/dense/error/offline/restored states.
|
||||
7. Verify light/dark, English/Chinese, small phone, large text.
|
||||
8. Build and test Android release after each large module.
|
||||
|
||||
## Risk Notes
|
||||
|
||||
- Navigation changes can affect user familiarity; do it on a development branch, not the already submitted release.
|
||||
- More visual richness can reduce performance if blur/shadow/ambient animation is overused.
|
||||
- Too much plant imagery may feel childish or narrow.
|
||||
- Too much bento/card structure may look generic.
|
||||
- Too much dark precision may feel cold.
|
||||
|
||||
## Decision For v1.0.2
|
||||
|
||||
Use Calm Modular Editorial unless the user chooses a stronger expressive direction. Start with Today and Focus because they define whether the product feels real.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Focus Seed 1.0.2 Local Database Product Loop
|
||||
|
||||
Status: historical implementation/reference plan. Superseded for active product direction by `../product-current-baseline.md`.
|
||||
|
||||
## Decision Header
|
||||
|
||||
- Objective: move Focus Seed from a simple local MVP into a richer offline-first product with durable history, user-facing settings, and basic retention feedback.
|
||||
- Worth doing: yes. History and statistics need relational data; the current JSON document is no longer the right foundation.
|
||||
- Scale: large-high-risk. This changes persistence, state loading, settings IA, and Journey value.
|
||||
- Mode: full workflow with checkpoints.
|
||||
- Success evidence: `flutter analyze`, `flutter test`, release AAB build, manifest permission review, mojibake scan, and emulator smoke on Today/Journey/Me.
|
||||
- Key assumption: no account, no cloud, no server, no ads/billing SDKs, no media permissions in this release.
|
||||
|
||||
## Product Boundary
|
||||
|
||||
Focus Seed remains a private local focus companion. The 1.0.2 goal is not to add breadth for its own sake; it is to close the daily loop:
|
||||
|
||||
1. Pick one daily priority.
|
||||
2. Complete focused time.
|
||||
3. Mark a few habits.
|
||||
4. Capture one reflection.
|
||||
5. See meaningful progress over time.
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
- No LLM.
|
||||
- No account or cloud sync.
|
||||
- No ads, subscriptions, billing, Firebase, or analytics SDK.
|
||||
- No user photo/video editing in 1.0.2. Media would add storage, privacy, permission, backup, and UI complexity before the core loop is strong enough.
|
||||
- No user-facing JSON import/export. JSON can remain a debug-only migration tool if needed.
|
||||
|
||||
## Data Architecture
|
||||
|
||||
Use Drift + SQLite as the local persistence boundary.
|
||||
|
||||
Tables:
|
||||
|
||||
- `daily_intentions`: daily priority text by date.
|
||||
- `focus_sessions`: planned minutes, actual seconds, start/end timestamps, completion status.
|
||||
- `habits`: title, sort order, archived timestamp.
|
||||
- `habit_logs`: habit completion by date.
|
||||
- `reflections`: mood, note, date.
|
||||
- `app_settings`: small key/value settings such as default focus duration.
|
||||
|
||||
Repository boundary:
|
||||
|
||||
- UI and controllers talk to a repository/store interface.
|
||||
- Drift implementation owns queries and migrations.
|
||||
- Existing `AppState` can remain as an application snapshot while the database becomes source of truth.
|
||||
- V1 JSON document is migrated once into SQLite, then the app reads from SQLite.
|
||||
|
||||
## Settings IA
|
||||
|
||||
User-facing settings:
|
||||
|
||||
- Appearance: theme.
|
||||
- Focus preferences: default session duration.
|
||||
- Privacy and data: local-only explanation, clear today's data, clear all local data.
|
||||
- About: version and privacy summary.
|
||||
|
||||
Debug-only settings:
|
||||
|
||||
- Language switch.
|
||||
- Developer data tools only if needed for migration QA.
|
||||
|
||||
Remove from user-facing UI:
|
||||
|
||||
- Export JSON.
|
||||
- Import JSON.
|
||||
|
||||
## Journey Enhancements
|
||||
|
||||
P0 for Journey:
|
||||
|
||||
- Show useful progress at a glance, not just raw recent records.
|
||||
|
||||
Add:
|
||||
|
||||
- Current streak.
|
||||
- Focus minutes this week.
|
||||
- Habit completion this week.
|
||||
- Reflection count this week.
|
||||
- Recent focus sessions.
|
||||
- Recent reflections.
|
||||
|
||||
## Today Enhancements
|
||||
|
||||
Copy:
|
||||
|
||||
- Replace "Choose one outcome" with user language: "What matters today?" / "写下今天最重要的一件事".
|
||||
- Replace "Today's intention" with "Today's priority" / "今日重点".
|
||||
|
||||
Interaction:
|
||||
|
||||
- Add small success feedback after completing a focus session, habit, or reflection.
|
||||
- Keep P0 action visible; no decorative element may compete with the timer or priority.
|
||||
- Use implicit animations only; no continuous distracting motion.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 0: Plan checkpoint
|
||||
|
||||
- Create branch.
|
||||
- Add this plan.
|
||||
- Commit the plan.
|
||||
|
||||
### Phase 1: Database foundation
|
||||
|
||||
- Add Drift dependencies.
|
||||
- Create database tables and DAO/repository.
|
||||
- Add migration from existing local JSON state.
|
||||
- Keep controllers stable by returning an `AppState` snapshot.
|
||||
- Tests: migration, CRUD, statistics.
|
||||
- Commit.
|
||||
|
||||
### Phase 2: Settings cleanup
|
||||
|
||||
- Remove user-facing JSON import/export.
|
||||
- Add clear today's data and clear all data confirmations with impact text.
|
||||
- Keep language switch debug-only.
|
||||
- Tests: settings visibility and destructive confirmations.
|
||||
- Commit.
|
||||
|
||||
### Phase 3: Retention value
|
||||
|
||||
- Add Journey summary cards and history sections backed by database queries.
|
||||
- Add focus preference for default duration if safe.
|
||||
- Tests: statistics and visible Journey labels.
|
||||
- Commit.
|
||||
|
||||
### Phase 4: QA and release artifact
|
||||
|
||||
- Mojibake scan.
|
||||
- `flutter analyze`.
|
||||
- `flutter test`.
|
||||
- `flutter build appbundle --release`.
|
||||
- Emulator smoke for Today/Journey/Me.
|
||||
- Commit final polish.
|
||||
|
||||
## Acceptance Gate
|
||||
|
||||
1. No user-facing JSON wording.
|
||||
2. No new Android runtime permissions.
|
||||
3. Existing installed 1.0.1 data migrates.
|
||||
4. New install starts with local database seed data.
|
||||
5. Journey gives at least three useful progress signals.
|
||||
6. Settings reads as user product settings, not developer tools.
|
||||
7. Worktree is clean after checkpoint commit.
|
||||
@@ -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
|
||||
+54
-60
@@ -1,6 +1,6 @@
|
||||
# Focus Seed Architecture
|
||||
|
||||
Status: refactored from single-file prototype into modular Flutter structure.
|
||||
Status: current release architecture for Focus Seed 1.0.2.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
@@ -8,77 +8,71 @@ Status: refactored from single-file prototype into modular Flutter structure.
|
||||
lib/
|
||||
main.dart
|
||||
src/
|
||||
app/
|
||||
focus_seed_app.dart
|
||||
app_theme.dart
|
||||
core/
|
||||
date_keys.dart
|
||||
iterable_extensions.dart
|
||||
json_utils.dart
|
||||
data/
|
||||
focus_seed_store.dart
|
||||
models/
|
||||
app_state.dart
|
||||
focus_session.dart
|
||||
habit.dart
|
||||
mood.dart
|
||||
reflection_entry.dart
|
||||
services/
|
||||
analytics_provider.dart
|
||||
monetization_provider.dart
|
||||
features/
|
||||
shell/
|
||||
focus_seed_home.dart
|
||||
home/
|
||||
home_screen.dart
|
||||
timer/
|
||||
timer_screen.dart
|
||||
habits/
|
||||
habits_screen.dart
|
||||
reflection/
|
||||
reflection_screen.dart
|
||||
settings/
|
||||
settings_screen.dart
|
||||
widgets/
|
||||
app_scaffold.dart
|
||||
dialogs.dart
|
||||
empty_state.dart
|
||||
habit_row.dart
|
||||
inline_editable_text.dart
|
||||
loading_view.dart
|
||||
metric_tile.dart
|
||||
section_label.dart
|
||||
seed_card.dart
|
||||
settings_tile.dart
|
||||
weekly_progress.dart
|
||||
app/ app shell, theme, ambience, localization setup
|
||||
application/ product controllers and timer lifecycle
|
||||
core/ date keys, focus durations, ambience, utilities
|
||||
data/ local persistence boundary
|
||||
data/local/ Drift SQLite database
|
||||
models/ immutable product models and derived state
|
||||
services/ no-op analytics and monetization interfaces
|
||||
features/ Today, Focus, Records, Me, habits, onboarding, reflection, shell
|
||||
widgets/ reusable panels, dialogs, headers, loading, illustrations
|
||||
```
|
||||
|
||||
## Responsibility Boundaries
|
||||
|
||||
- `main.dart`: process entry only.
|
||||
- `src/app`: app shell and theme.
|
||||
- `src/models`: serializable domain objects.
|
||||
- `src/data`: persistence boundary using `shared_preferences`.
|
||||
- `src/services`: no-op interfaces for analytics and monetization.
|
||||
- `src/features/shell`: state orchestration and feature routing.
|
||||
- `src/app`: app-level theme, locale policy, ambience updates, and shell bootstrapping.
|
||||
- `src/application`: user commands, save rollback, local analytics events, and timer lifecycle.
|
||||
- `src/core`: framework-light utilities and product constants.
|
||||
- `src/data`: persistence boundary used by controllers.
|
||||
- `src/data/local`: Drift tables, SQLite connection, and product-data clearing.
|
||||
- `src/models`: product snapshots, sessions, habits, reflections, mood, records, and progress models.
|
||||
- `src/services`: SDK-light interfaces with no-op implementations.
|
||||
- `src/features/shell`: top-level navigation and controller wiring.
|
||||
- `src/features/*`: screen-level UI and user flows.
|
||||
- `src/widgets`: reusable UI components and dialogs.
|
||||
- `src/core`: framework-light utilities.
|
||||
- `src/widgets`: reusable UI primitives.
|
||||
|
||||
Widgets render state and forward intent. Controllers own commands. `FocusSeedStore` is the durable persistence boundary. Feature UI does not write to SQLite directly.
|
||||
|
||||
## Current Product Structure
|
||||
|
||||
Primary destinations:
|
||||
|
||||
- Today: daily focus, timer entry, habit summary, reflection prompt, and loop progress.
|
||||
- Focus: immersive timer and active-session context.
|
||||
- Records: completed focus sessions, reflections, and lightweight history.
|
||||
- Me: preferences, privacy, local data controls, and about/legal surfaces.
|
||||
|
||||
Habits are a Today support flow, not a primary tab. Growth/Journey was removed from the V1 primary surface; review belongs in Records until a future feature has enough depth to justify a dedicated destination.
|
||||
|
||||
## Persistence
|
||||
|
||||
Focus Seed is offline-first and stores product data locally with Drift/SQLite:
|
||||
|
||||
- `daily_intentions`
|
||||
- `focus_sessions`
|
||||
- `habits`
|
||||
- `habit_logs`
|
||||
- `reflections`
|
||||
- `app_settings`
|
||||
|
||||
The current user-facing app exposes no manual data transfer tools. Existing legacy local preference data is migrated once into SQLite when present, then removed. Small app preferences such as appearance and development language selection remain local preferences.
|
||||
|
||||
## Current Design Decisions
|
||||
|
||||
- No real ad SDK in V1.
|
||||
- No real billing SDK in V1.
|
||||
- No analytics SDK in V1.
|
||||
- No network dependency.
|
||||
- JSON import/export uses copy/paste to avoid file permissions.
|
||||
- Shell owns state until a future state-management package is justified.
|
||||
- No account, server, or network dependency.
|
||||
- Default focus duration is a user-facing local setting.
|
||||
- Clear today resets only today's loop.
|
||||
- Delete all data clears local product records while preserving app preference behavior where appropriate.
|
||||
|
||||
## Upgrade Path
|
||||
|
||||
Next modularization steps when needed:
|
||||
|
||||
- Move `src/app`, `src/core`, `src/models`, `src/services`, and `src/widgets` into shared packages.
|
||||
- Replace shell state with Riverpod once multiple apps/features share the same state patterns.
|
||||
- Add real `AnalyticsProvider`, `MonetizationProvider`, and remote config implementations only with privacy/Data safety updates.
|
||||
- Add golden tests for key screens before scaling the app factory.
|
||||
- Keep Drift as the local source of truth while the product remains offline-first.
|
||||
- Add DAO-style query methods before history grows large enough to make whole-snapshot writes inefficient.
|
||||
- Add Riverpod or a repository package only when multiple apps or features need shared state patterns.
|
||||
- Add real analytics, ads, billing, remote config, or sync only with privacy, Data safety, policy, and tests in the same release.
|
||||
- Add golden or screenshot tests before scaling launch screenshots or visual variants.
|
||||
|
||||
@@ -31,11 +31,11 @@ Official references:
|
||||
|
||||
2. Publish an internal testing release.
|
||||
- Upload `build/app/outputs/bundle/release/app-release.aab`.
|
||||
- Use release name `1.0.0 internal`.
|
||||
- Use release name `1.0.2 internal`.
|
||||
- Release notes:
|
||||
```text
|
||||
First internal test release.
|
||||
Includes focus timer, habit tracking, daily reflection, local progress stats, JSON export/import, and local privacy controls.
|
||||
Internal test release for Focus Seed.
|
||||
Includes focus timer, habit tracking, daily reflection, local progress stats, default focus duration, and local privacy controls.
|
||||
```
|
||||
- Add yourself and one or two trusted accounts if available.
|
||||
- Use this to confirm Play delivery and installation.
|
||||
@@ -45,11 +45,11 @@ Official references:
|
||||
- Create track name: `Focus Seed V1 closed test`.
|
||||
- Countries/regions: use English-first regions from `region-localization-strategy.md`.
|
||||
- Upload the same AAB.
|
||||
- Release name: `1.0.0 closed test`.
|
||||
- Use release name `1.0.2 closed test`.
|
||||
- Release notes:
|
||||
```text
|
||||
First closed test release for Focus Seed.
|
||||
Please test the Today, Timer, Habits, Reflect, Settings, Export JSON, Import JSON, and Clear local data flows.
|
||||
Closed test release for Focus Seed.
|
||||
Please test Today, Focus, Records, Me, habit management from Today, default focus duration, clear today's loop, delete local records, and privacy information.
|
||||
```
|
||||
|
||||
4. Add testers.
|
||||
@@ -58,11 +58,11 @@ Official references:
|
||||
- Minimum passing target: 12 opted-in testers.
|
||||
- Tester emails must be Google accounts that can access Google Play.
|
||||
- Adding emails is not enough. Each tester must open the opt-in link and join the test.
|
||||
- If uploading CSV, use one email address per line with no comma and no header row. Use `tester-email-list-no-header.csv` as the safer template.
|
||||
- If uploading CSV, use one email address per line with no comma and no header row.
|
||||
|
||||
5. Send tester instructions.
|
||||
- Send the opt-in link from Play Console.
|
||||
- Ask each tester to confirm after they see the "You're a tester" or equivalent joined state.
|
||||
- Ask each tester to confirm after they see the joined tester state.
|
||||
- Ask each tester to install and open the app at least once.
|
||||
|
||||
6. Track 14 days.
|
||||
@@ -74,14 +74,14 @@ Official references:
|
||||
- Apply from the Play Console dashboard when eligible.
|
||||
- Answer questions using the notes in this file.
|
||||
|
||||
## 12 Tester Strategy For A Solo Developer
|
||||
## Tester Strategy For A Solo Developer
|
||||
|
||||
Best compliant sources:
|
||||
|
||||
- Friends and family with Android phones and Google accounts.
|
||||
- Former classmates, coworkers, or community contacts.
|
||||
- Small indie developer groups where members exchange real testing help.
|
||||
- Productivity/focus communities where you honestly ask for feedback.
|
||||
- Productivity or focus communities where you honestly ask for feedback.
|
||||
- Existing users from your personal network.
|
||||
|
||||
Recommended count:
|
||||
@@ -108,35 +108,7 @@ Tester success checklist:
|
||||
- Tester tries at least one real flow.
|
||||
- Tester sends short feedback privately.
|
||||
|
||||
## Tester Invitation Message - Chinese
|
||||
|
||||
```text
|
||||
我在 Google Play 上做一个新的 Android 应用封闭测试,名字是 Focus Seed。
|
||||
它是一个轻量的专注计时、习惯追踪和每日反思工具,不需要账号、不收费、没有广告。
|
||||
|
||||
能麻烦你帮我测试一下吗?
|
||||
|
||||
步骤:
|
||||
1. 用你的 Android 手机打开这个测试链接:
|
||||
[粘贴 Play Console opt-in 链接]
|
||||
2. 确认加入测试。
|
||||
3. 从 Google Play 安装 Focus Seed。
|
||||
4. 打开 App,试一下:
|
||||
- Today 页面设置一个今日重点
|
||||
- Timer 页面启动或完成一次专注
|
||||
- Habits 页面勾选一个习惯
|
||||
- Reflect 页面写一句反思
|
||||
- Settings 页面看一下隐私/导出入口
|
||||
5. 测完后告诉我:
|
||||
- 是否能正常安装和打开
|
||||
- 哪个页面最不清楚
|
||||
- 有没有崩溃、卡顿、文字看不清或按钮点不了
|
||||
|
||||
这个测试需要保持加入状态 14 天。你不需要每天使用,但请不要退出测试。
|
||||
谢谢!
|
||||
```
|
||||
|
||||
## Tester Invitation Message - English
|
||||
## Tester Invitation Message
|
||||
|
||||
```text
|
||||
I am running a closed Google Play test for a new Android app called Focus Seed.
|
||||
@@ -150,11 +122,13 @@ Steps:
|
||||
2. Join the test.
|
||||
3. Install Focus Seed from Google Play.
|
||||
4. Try these flows:
|
||||
- Set a daily anchor on Today
|
||||
- Set today's focus on Today
|
||||
- Start or complete a focus timer
|
||||
- Check off a habit
|
||||
- Save a short reflection
|
||||
- Open Settings and review privacy/export controls
|
||||
- Open Records and view progress/history
|
||||
- Open Me and change the default focus duration
|
||||
- Review privacy information and local data controls
|
||||
5. Send me quick feedback:
|
||||
- Did install/open work?
|
||||
- Was any screen confusing?
|
||||
@@ -166,23 +140,20 @@ Thank you!
|
||||
|
||||
## Quick Feedback Questions
|
||||
|
||||
Send these as a message or simple form:
|
||||
|
||||
```text
|
||||
1. Your phone model:
|
||||
2. Android version, if known:
|
||||
3. Did the app install from Google Play? Yes / No
|
||||
4. Did the app open successfully? Yes / No
|
||||
5. Which screen did you try? Today / Timer / Habits / Reflect / Settings
|
||||
6. Any crash, freeze, lag, or layout issue?
|
||||
7. What felt confusing?
|
||||
8. One thing you would improve:
|
||||
5. Which screen did you try? Today / Focus / Records / Me / habit manager
|
||||
6. Did default focus duration work as expected?
|
||||
7. Any crash, freeze, lag, or layout issue?
|
||||
8. What felt confusing?
|
||||
9. One thing you would improve:
|
||||
```
|
||||
|
||||
## Tester Tracking Table
|
||||
|
||||
Use a spreadsheet or notes app:
|
||||
|
||||
```text
|
||||
Tester name | Google account email | Invited | Opted in | Installed | Opened app | Feedback received | Still opted in day 14
|
||||
```
|
||||
@@ -199,8 +170,8 @@ Target:
|
||||
Use these points when Play Console asks about the closed test:
|
||||
|
||||
- App purpose: Focus Seed is a private focus timer, habit tracker, and daily reflection app.
|
||||
- Test scope: Today anchor, timer, habits, reflection, settings, export/import, and local data deletion.
|
||||
- Test scope: Today focus, timer, habits, reflection, Records/history, Me/settings, default focus duration, clear today's loop, delete local records, and privacy information.
|
||||
- Tester recruitment: invited trusted Android users through direct email/message using the Play Console opt-in link.
|
||||
- Feedback collected: installation success, first-run clarity, timer usability, habit check-off, reflection save flow, settings/privacy clarity, layout or crash reports.
|
||||
- Actions taken: fixed native Android launch package path, removed unfinished ads/subscription placeholders from settings, verified release build on Android 14.
|
||||
- Actions taken: verified local-only storage, removed obsolete data-transfer wording, added default focus duration, and checked release-sensitive privacy wording.
|
||||
- Readiness: no account, no server, no ads, no billing, no analytics SDK, no sensitive permissions, privacy policy and Data safety match the shipped app.
|
||||
|
||||
@@ -8,8 +8,8 @@ This guidance assumes the submitted build is the current Focus Seed V1:
|
||||
- No analytics SDK.
|
||||
- No billing SDK.
|
||||
- No network permission.
|
||||
- Data stored locally with `shared_preferences`.
|
||||
- Export/import is user-controlled copy/paste JSON.
|
||||
- Data stored locally with Drift/SQLite plus small local preferences.
|
||||
- User can clear today's loop or delete local app records from settings.
|
||||
|
||||
## Suggested Data Safety Answers
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ Private focus timer, habit tracker, and one-minute daily reflection.
|
||||
|
||||
## Full Description
|
||||
|
||||
Focus Seed helps you turn each day into a small, trackable win. Choose today’s anchor task, start a focused timer, mark habits as done, and write a short reflection when you finish.
|
||||
Focus Seed helps you turn each day into a small, trackable win. Choose today's anchor task, start a focused timer, mark habits as done, and write a short reflection when you finish.
|
||||
|
||||
Everything works locally on your device. No account is required, and your focus sessions, habits, and reflections stay on your phone unless you choose to export them.
|
||||
Everything works locally on your device. No account is required, and your focus sessions, habits, reflections, and preferences stay on your phone.
|
||||
|
||||
Features:
|
||||
|
||||
@@ -22,7 +22,7 @@ Features:
|
||||
- One-minute reflection log
|
||||
- Mood tags
|
||||
- Local 7-day progress view
|
||||
- JSON export and import
|
||||
- Default focus duration preference
|
||||
- Clear local-data privacy controls
|
||||
|
||||
Focus Seed is designed for people who want a lightweight productivity tool without accounts, feeds, or complicated setup.
|
||||
@@ -47,11 +47,11 @@ focus timer, habit tracker, reflection, productivity, offline, private, daily ro
|
||||
|
||||
## Release Notes
|
||||
|
||||
First release:
|
||||
Current release:
|
||||
|
||||
- Focus timer
|
||||
- Habit tracker
|
||||
- Daily reflection
|
||||
- Local progress stats
|
||||
- Local JSON export/import
|
||||
- Default focus duration setting
|
||||
- In-app privacy controls
|
||||
|
||||
@@ -71,17 +71,15 @@
|
||||
<li>Habit names and completion dates</li>
|
||||
<li>Focus session completion time and duration</li>
|
||||
<li>Reflection notes and mood tags</li>
|
||||
<li>App preferences such as appearance and default focus duration</li>
|
||||
<li>Basic app state needed for the app to work</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data Sharing</h2>
|
||||
<p>Focus Seed V1 does not share your app data with the developer or third parties.</p>
|
||||
|
||||
<h2>Export And Import</h2>
|
||||
<p>You can export your local data as JSON from the app settings. You control where that exported text is copied or stored. You can also paste a previous JSON export into the app to import it.</p>
|
||||
|
||||
<h2>Data Deletion</h2>
|
||||
<p>You can clear local app data from Settings. You can also remove data by uninstalling the app or clearing app storage in Android settings.</p>
|
||||
<p>You can clear today's loop or delete local app records from Settings. You can also remove data by uninstalling the app or clearing app storage in Android settings.</p>
|
||||
|
||||
<h2>Ads, Analytics, And Purchases</h2>
|
||||
<p>Focus Seed V1 does not include advertising SDKs, analytics SDKs, or in-app purchase SDKs.</p>
|
||||
|
||||
@@ -16,19 +16,16 @@ The app stores the following data locally on your device:
|
||||
- Habit names and completion dates
|
||||
- Focus session completion time and duration
|
||||
- Reflection notes and mood tags
|
||||
- App preferences such as appearance and default focus duration
|
||||
- Basic app state needed for the app to work
|
||||
|
||||
## Data Sharing
|
||||
|
||||
Focus Seed V1 does not share your app data with the developer or third parties.
|
||||
|
||||
## Export And Import
|
||||
|
||||
You can export your local data as JSON from the app settings. You control where that exported text is copied or stored. You can also paste a previous JSON export into the app to import it.
|
||||
|
||||
## Data Deletion
|
||||
|
||||
You can clear local app data from Settings. You can also remove data by uninstalling the app or clearing app storage in Android settings.
|
||||
You can clear today's loop or delete local app records from Settings. You can also remove data by uninstalling the app or clearing app storage in Android settings.
|
||||
|
||||
## Ads, Analytics, And Purchases
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Focus Seed Product Brief
|
||||
|
||||
Status: V1 release candidate
|
||||
Status: historical release reference. Superseded for active product decisions by `docs/product-current-baseline.md`.
|
||||
|
||||
## Positioning
|
||||
|
||||
@@ -13,10 +13,11 @@ Focus Seed is a private, offline-first focus timer, habit tracker, and one-minut
|
||||
- Habit creation, rename, delete, and daily check-off.
|
||||
- One-minute reflection with mood tag.
|
||||
- 7-day progress pulse.
|
||||
- Local JSON export and import by copy/paste.
|
||||
- In-app privacy explanation.
|
||||
- Local data controls: clear today's loop or delete all local records.
|
||||
- User-facing settings for appearance and default focus duration.
|
||||
- No-op monetization and analytics interfaces.
|
||||
- English-only UI and store listing for the first launch wave.
|
||||
- Release defaults to English UI and English store listing for the first launch wave. Chinese remains development/testing support only.
|
||||
|
||||
## V1 Exclusions
|
||||
|
||||
@@ -50,7 +51,7 @@ Defer EU/EEA, United Kingdom, Switzerland, South Korea, Japan, Brazil/LATAM, and
|
||||
## Future Revenue Path
|
||||
|
||||
- V2/V3: optional low-interruption ads after retention validation.
|
||||
- V4/V5: one-time unlock or subscription for advanced statistics, themes, export templates, widgets, and backup/sync.
|
||||
- V4/V5: one-time unlock or subscription for advanced statistics, premium themes, widgets, and backup/sync.
|
||||
- Free core loop remains usable.
|
||||
|
||||
## App IDs
|
||||
@@ -59,6 +60,6 @@ Defer EU/EEA, United Kingdom, Switzerland, South Korea, Japan, Brazil/LATAM, and
|
||||
- Developer / brand: leavysong
|
||||
- Support email: leavysong@gmail.com
|
||||
- Package/application ID: `com.dailyanchor.focusseed`
|
||||
- Version: `1.0.0+1`
|
||||
- Version: `1.0.2+3`
|
||||
- Android min SDK: 26
|
||||
- Android target SDK: 35
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Focus Seed 1.0.1 Experience Brief
|
||||
|
||||
Status: approved product and design principles, visual route not yet selected.
|
||||
Status: historical experience reference. Superseded for active product direction by `../../docs/product-current-baseline.md`.
|
||||
|
||||
Previous status: approved product and design principles, visual route not yet selected.
|
||||
|
||||
## Product Intent
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 220 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
@@ -18,7 +18,7 @@ Recommended phone screenshots:
|
||||
2. Focus timer with progress ring.
|
||||
3. Habits screen with completed and pending habits.
|
||||
4. Reflection screen with mood tags and text area.
|
||||
5. Settings screen showing local data export/import and privacy.
|
||||
5. Settings screen showing appearance, default focus duration, local data controls, and privacy.
|
||||
|
||||
Suggested captions:
|
||||
|
||||
@@ -26,7 +26,7 @@ Suggested captions:
|
||||
- Start a calm focus session.
|
||||
- Track tiny habits without an account.
|
||||
- Finish with a one-minute reflection.
|
||||
- Keep your data local and export when needed.
|
||||
- Keep your records local and under your control.
|
||||
|
||||
Feature graphic direction:
|
||||
|
||||
|
||||
+192
-27
@@ -1,52 +1,217 @@
|
||||
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 forest = Color(0xFF08785B);
|
||||
static const teal = Color(0xFF0A8179);
|
||||
static const moss = Color(0xFF6C8F6B);
|
||||
static const mint = Color(0xFFA8E6C1);
|
||||
static const ultramarine = Color(0xFF2E6FDB);
|
||||
static const sky = Color(0xFF42A5F5);
|
||||
static const amber = Color(0xFFE3A321);
|
||||
static const coral = Color(0xFFD9654F);
|
||||
static const violet = Color(0xFF7257C8);
|
||||
static const paper = Color(0xFFFAFBFF);
|
||||
static const porcelain = Color(0xFFF4F7FC);
|
||||
|
||||
static ThemeData light() {
|
||||
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(0xFFF3F8FC),
|
||||
DayPhase.day => porcelain,
|
||||
DayPhase.dusk => const Color(0xFFFBF6F4),
|
||||
DayPhase.night => const Color(0xFFF2F5FA),
|
||||
};
|
||||
final scheme = ColorScheme.fromSeed(seedColor: ultramarine).copyWith(
|
||||
primary: ultramarine,
|
||||
onPrimary: Colors.white,
|
||||
secondary: ultramarine,
|
||||
tertiary: amber,
|
||||
surface: surface,
|
||||
onSurface: ink,
|
||||
surfaceContainerLowest: const Color(0xFFFFFEFA),
|
||||
surfaceContainerLow: const Color(0xFFEFF3FA),
|
||||
surfaceContainer: const Color(0xFFE4EAF4),
|
||||
surfaceContainerHigh: const Color(0xFFD6DEEB),
|
||||
outline: const Color(0xFF9EA9B8),
|
||||
outlineVariant: const Color(0xFFD5DEEA),
|
||||
);
|
||||
return _base(scheme);
|
||||
return _base(scheme, phase);
|
||||
}
|
||||
|
||||
static ThemeData _base(ColorScheme scheme) {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
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: const Color(0xFF8FBAFF),
|
||||
onPrimary: const Color(0xFF071226),
|
||||
secondary: const Color(0xFF8FBAFF),
|
||||
tertiary: const Color(0xFFFFD782),
|
||||
surface: surface,
|
||||
onSurface: const Color(0xFFF1F5F1),
|
||||
surfaceContainerLowest: const Color(0xFF0D1210),
|
||||
surfaceContainerLow: const Color(0xFF151C24),
|
||||
surfaceContainer: const Color(0xFF1B2430),
|
||||
surfaceContainerHigh: const Color(0xFF253142),
|
||||
outline: const Color(0xFF7F8EA4),
|
||||
outlineVariant: const Color(0xFF344153),
|
||||
);
|
||||
return _base(scheme, phase);
|
||||
}
|
||||
|
||||
static ThemeData _base(ColorScheme scheme, DayPhase phase) {
|
||||
final base = ThemeData(useMaterial3: true, colorScheme: scheme, scaffoldBackgroundColor: scheme.surface);
|
||||
final 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,
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
indicatorColor: scheme.primaryContainer,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(_) => const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
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.10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
height: 72,
|
||||
elevation: 0,
|
||||
backgroundColor: scheme.surfaceContainerLowest,
|
||||
indicatorColor: scheme.primary.withValues(alpha: 0.13),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData(size: 23, color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant)),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) => TextStyle(fontSize: 12, fontWeight: states.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: scheme.surfaceContainerLow,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.22))),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.22))),
|
||||
),
|
||||
chipTheme: base.chipTheme.copyWith(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.38))),
|
||||
dividerTheme: DividerThemeData(color: scheme.outlineVariant.withValues(alpha: 0.64), space: 1, thickness: 1),
|
||||
dialogTheme: DialogTheme(backgroundColor: scheme.surfaceContainerLowest, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
backgroundColor: scheme.surface,
|
||||
modalBackgroundColor: scheme.surface,
|
||||
showDragHandle: true,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
),
|
||||
extensions: [FocusSeedVisuals.forPhase(phase, scheme.brightness)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
|
||||
const FocusSeedVisuals({
|
||||
required this.ambientTint,
|
||||
required this.brand,
|
||||
required this.action,
|
||||
required this.focus,
|
||||
required this.habit,
|
||||
required this.reflection,
|
||||
required this.success,
|
||||
required this.privacy,
|
||||
required this.warning,
|
||||
required this.shadow,
|
||||
});
|
||||
|
||||
final Color ambientTint;
|
||||
final Color brand;
|
||||
final Color action;
|
||||
final Color focus;
|
||||
final Color habit;
|
||||
final Color reflection;
|
||||
final Color success;
|
||||
final Color privacy;
|
||||
final Color warning;
|
||||
final Color shadow;
|
||||
|
||||
static FocusSeedVisuals of(BuildContext context) {
|
||||
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.14 : 0.18),
|
||||
brand: isDark ? const Color(0xFFF1F5F1) : AppTheme.ink,
|
||||
action: isDark ? const Color(0xFFFFB09D) : AppTheme.coral,
|
||||
focus: isDark ? const Color(0xFF8FBAFF) : AppTheme.ultramarine,
|
||||
habit: isDark ? const Color(0xFFFFD782) : AppTheme.amber,
|
||||
reflection: isDark ? const Color(0xFFC7B8FF) : AppTheme.violet,
|
||||
success: isDark ? AppTheme.mint : AppTheme.forest,
|
||||
privacy: isDark ? const Color(0xFF8DDFC1) : AppTheme.forest,
|
||||
warning: isDark ? const Color(0xFFFFB4A8) : AppTheme.coral,
|
||||
shadow: Colors.black.withValues(alpha: isDark ? 0.32 : 0.10),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
FocusSeedVisuals copyWith({Color? ambientTint, Color? brand, Color? action, Color? focus, Color? habit, Color? reflection, Color? success, Color? privacy, Color? warning, Color? shadow}) {
|
||||
return FocusSeedVisuals(
|
||||
ambientTint: ambientTint ?? this.ambientTint,
|
||||
brand: brand ?? this.brand,
|
||||
action: action ?? this.action,
|
||||
focus: focus ?? this.focus,
|
||||
habit: habit ?? this.habit,
|
||||
reflection: reflection ?? this.reflection,
|
||||
success: success ?? this.success,
|
||||
privacy: privacy ?? this.privacy,
|
||||
warning: warning ?? this.warning,
|
||||
shadow: shadow ?? this.shadow,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
FocusSeedVisuals lerp(covariant FocusSeedVisuals? other, double t) {
|
||||
if (other == null) {
|
||||
return this;
|
||||
}
|
||||
return FocusSeedVisuals(
|
||||
ambientTint: Color.lerp(ambientTint, other.ambientTint, t)!,
|
||||
brand: Color.lerp(brand, other.brand, t)!,
|
||||
action: Color.lerp(action, other.action, t)!,
|
||||
focus: Color.lerp(focus, other.focus, t)!,
|
||||
habit: Color.lerp(habit, other.habit, t)!,
|
||||
reflection: Color.lerp(reflection, other.reflection, t)!,
|
||||
success: Color.lerp(success, other.success, t)!,
|
||||
privacy: Color.lerp(privacy, other.privacy, t)!,
|
||||
warning: Color.lerp(warning, other.warning, t)!,
|
||||
shadow: Color.lerp(shadow, other.shadow, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,188 @@
|
||||
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 '../data/focus_seed_store.dart';
|
||||
import '../features/onboarding/welcome_screen.dart';
|
||||
import '../features/shell/focus_seed_home.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../models/appearance_preference.dart';
|
||||
import '../models/language_preference.dart';
|
||||
import '../widgets/loading_view.dart';
|
||||
import 'app_theme.dart';
|
||||
|
||||
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,
|
||||
this.focusSeedStore,
|
||||
this.showWelcomeOnFirstRun = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Locale? locale;
|
||||
final FocusSeedStore? focusSeedStore;
|
||||
final bool showWelcomeOnFirstRun;
|
||||
|
||||
@override
|
||||
State<FocusSeedApp> createState() => _FocusSeedAppState();
|
||||
}
|
||||
|
||||
class _FocusSeedAppState extends State<FocusSeedApp>
|
||||
with WidgetsBindingObserver {
|
||||
final _preferencesStore = AppPreferencesStore();
|
||||
AppearancePreference _appearance = AppearancePreference.system;
|
||||
LanguagePreference _language = LanguagePreference.defaultLocale;
|
||||
bool _onboardingComplete = true;
|
||||
bool _landingSetupComplete = true;
|
||||
bool _showLandingSetup = false;
|
||||
bool _preferencesLoaded = false;
|
||||
DayPhase _dayPhase = dayPhaseFor(DateTime.now());
|
||||
Timer? _phaseTimer;
|
||||
|
||||
@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, language, firstRunProgress) =
|
||||
await (
|
||||
_preferencesStore.loadAppearance(),
|
||||
_preferencesStore.loadLanguage(),
|
||||
_preferencesStore.loadFirstRunProgress(),
|
||||
).wait;
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_appearance = appearance;
|
||||
_language = language;
|
||||
_onboardingComplete = firstRunProgress.onboardingComplete;
|
||||
_landingSetupComplete = firstRunProgress.landingSetupComplete;
|
||||
_showLandingSetup =
|
||||
widget.showWelcomeOnFirstRun &&
|
||||
firstRunProgress.onboardingComplete &&
|
||||
!firstRunProgress.landingSetupComplete;
|
||||
_preferencesLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
Future<void> _completeOnboarding() async {
|
||||
setState(() {
|
||||
_onboardingComplete = true;
|
||||
_showLandingSetup = true;
|
||||
});
|
||||
await _preferencesStore.saveOnboardingComplete();
|
||||
}
|
||||
|
||||
Future<void> _completeLandingSetup() async {
|
||||
setState(() {
|
||||
_landingSetupComplete = true;
|
||||
_showLandingSetup = false;
|
||||
});
|
||||
await _preferencesStore.saveLandingSetupComplete();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
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:
|
||||
!_preferencesLoaded
|
||||
? const Scaffold(body: SafeArea(child: LoadingView()))
|
||||
: widget.showWelcomeOnFirstRun && !_onboardingComplete
|
||||
? WelcomeScreen(
|
||||
onStart: _completeOnboarding,
|
||||
onSkip: _completeOnboarding,
|
||||
)
|
||||
: FocusSeedHome(
|
||||
focusSeedStore: widget.focusSeedStore,
|
||||
appearance: _appearance,
|
||||
onAppearanceChanged: _setAppearance,
|
||||
language: _language,
|
||||
showLanguageTools: !kReleaseMode && widget.locale == null,
|
||||
onLanguageChanged: _setLanguage,
|
||||
showLandingSetupInitially:
|
||||
widget.showWelcomeOnFirstRun &&
|
||||
(!_landingSetupComplete || _showLandingSetup),
|
||||
onLandingSetupComplete: () {
|
||||
unawaited(_completeLandingSetup());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_phaseTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../core/date_keys.dart';
|
||||
import '../core/focus_durations.dart';
|
||||
import '../data/focus_seed_store.dart';
|
||||
import '../models/app_state.dart';
|
||||
import '../models/focus_session.dart';
|
||||
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 title = value.trim();
|
||||
if (current == null || title.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
_analytics.track('intention_saved');
|
||||
return _commit(
|
||||
current.copyWith(
|
||||
anchor: title,
|
||||
anchorDateKey: todayKey(),
|
||||
selectedActionId: '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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, {int? actualSeconds}) async {
|
||||
final current = _state;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final today = todayKey();
|
||||
final anchor = current.activeAnchor(today);
|
||||
final session = FocusSession(
|
||||
id: 'session-${now.microsecondsSinceEpoch}',
|
||||
minutes: minutes,
|
||||
actualSeconds: actualSeconds,
|
||||
completedAt: now,
|
||||
intention: anchor,
|
||||
actionTitleSnapshot: anchor,
|
||||
);
|
||||
_analytics.track('focus_completed', {
|
||||
'minutes': minutes,
|
||||
'actualSeconds': session.actualSeconds,
|
||||
});
|
||||
return _commit(
|
||||
current.copyWith(
|
||||
anchor: anchor,
|
||||
anchorDateKey: today,
|
||||
sessions: [...current.sessions, session],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> saveReflection(String value, Mood mood) async {
|
||||
return saveReflectionForDate(todayKey(), value, mood);
|
||||
}
|
||||
|
||||
Future<bool> saveReflectionForDate(
|
||||
String dateKey,
|
||||
String value,
|
||||
Mood mood,
|
||||
) async {
|
||||
final current = _state;
|
||||
final trimmedNote = value.trim();
|
||||
final targetDateKey = dateKey.trim();
|
||||
if (current == null || targetDateKey.isEmpty || trimmedNote.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final entry = ReflectionEntry(
|
||||
id: 'reflection-${now.microsecondsSinceEpoch}',
|
||||
dateKey: targetDateKey,
|
||||
note: trimmedNote,
|
||||
mood: mood,
|
||||
createdAt: now,
|
||||
);
|
||||
final reflections = [
|
||||
...current.reflections.where((item) => item.dateKey != targetDateKey),
|
||||
entry,
|
||||
];
|
||||
_analytics.track('reflection_saved');
|
||||
return _commit(current.copyWith(reflections: reflections));
|
||||
}
|
||||
|
||||
Future<bool> setDefaultFocusMinutes(int minutes) async {
|
||||
final current = _state;
|
||||
if (current == null || !isFocusDurationPreset(minutes)) {
|
||||
return false;
|
||||
}
|
||||
if (current.defaultFocusMinutes == minutes) {
|
||||
return true;
|
||||
}
|
||||
_analytics.track('default_focus_minutes_changed', {'minutes': minutes});
|
||||
return _commit(current.copyWith(defaultFocusMinutes: minutes));
|
||||
}
|
||||
|
||||
Future<bool> clearData() async {
|
||||
final current = _state;
|
||||
return _commit(
|
||||
AppState.starter(defaultHabitTitles: _defaultHabitTitles).copyWith(
|
||||
defaultFocusMinutes:
|
||||
current?.defaultFocusMinutes ?? defaultFocusDurationMinutes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> clearToday() async {
|
||||
final current = _state;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
final today = todayKey();
|
||||
final habits = current.habits
|
||||
.map(
|
||||
(habit) => habit.copyWith(
|
||||
completedDates: Set<String>.from(habit.completedDates)
|
||||
..remove(today),
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final sessions = current.sessions
|
||||
.where((session) => dateKey(session.completedAt) != today)
|
||||
.toList(growable: false);
|
||||
final reflections = current.reflections
|
||||
.where((reflection) => reflection.dateKey != today)
|
||||
.toList(growable: false);
|
||||
final actions = current.actions
|
||||
.where((action) => action.dateKey != today)
|
||||
.toList(growable: false);
|
||||
final progressNotes = current.progressNotes
|
||||
.where((note) => note.dateKey != today)
|
||||
.toList(growable: false);
|
||||
_analytics.track('today_cleared');
|
||||
return _commit(
|
||||
current.copyWith(
|
||||
anchor: current.anchorDateKey == today ? '' : current.anchor,
|
||||
selectedActionId:
|
||||
current.anchorDateKey == today ? '' : current.selectedActionId,
|
||||
habits: habits,
|
||||
actions: actions,
|
||||
sessions: sessions,
|
||||
progressNotes: progressNotes,
|
||||
reflections: reflections,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,282 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../core/focus_durations.dart';
|
||||
import '../data/focus_timer_session_store.dart';
|
||||
|
||||
enum FocusTimerStatus { idle, running, paused, completed }
|
||||
|
||||
typedef FocusTimerCompleted =
|
||||
Future<bool> Function(int minutes, int actualSeconds);
|
||||
|
||||
class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
FocusTimerController({
|
||||
required FocusTimerCompleted onCompleted,
|
||||
FocusTimerSessionStore? sessionStore,
|
||||
int initialMinutes = defaultFocusDurationMinutes,
|
||||
}) : _onCompleted = onCompleted,
|
||||
_sessionStore = sessionStore,
|
||||
_selectedMinutes = initialMinutes,
|
||||
_remainingSeconds = initialMinutes * 60 {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
static const presets = focusDurationPresets;
|
||||
|
||||
final FocusTimerCompleted _onCompleted;
|
||||
final FocusTimerSessionStore? _sessionStore;
|
||||
Timer? _ticker;
|
||||
DateTime? _endsAt;
|
||||
int _selectedMinutes;
|
||||
int _remainingSeconds;
|
||||
FocusTimerStatus _status = FocusTimerStatus.idle;
|
||||
bool _isCompleting = false;
|
||||
|
||||
int get selectedMinutes => _selectedMinutes;
|
||||
int get remainingSeconds => _remainingSeconds;
|
||||
int get elapsedSeconds =>
|
||||
math.max(0, (_selectedMinutes * 60) - _remainingSeconds);
|
||||
FocusTimerStatus get status => _status;
|
||||
bool get isRunning => _status == FocusTimerStatus.running;
|
||||
bool get isPaused => _status == FocusTimerStatus.paused;
|
||||
bool get isCompleted => _status == FocusTimerStatus.completed;
|
||||
double get progress {
|
||||
final totalSeconds = _selectedMinutes * 60;
|
||||
return totalSeconds == 0
|
||||
? 0
|
||||
: (1 - (_remainingSeconds / totalSeconds)).clamp(0, 1);
|
||||
}
|
||||
|
||||
Future<bool> restoreSavedSession() async {
|
||||
final sessionStore = _sessionStore;
|
||||
if (sessionStore == null) {
|
||||
return false;
|
||||
}
|
||||
final snapshot = await sessionStore.load();
|
||||
if (snapshot == null) {
|
||||
return false;
|
||||
}
|
||||
if (!presets.contains(snapshot.selectedMinutes)) {
|
||||
await sessionStore.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
_selectedMinutes = snapshot.selectedMinutes;
|
||||
final totalSeconds = _selectedMinutes * 60;
|
||||
_remainingSeconds =
|
||||
snapshot.remainingSeconds.clamp(0, totalSeconds).toInt();
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
|
||||
switch (snapshot.status) {
|
||||
case FocusTimerSnapshotStatus.running:
|
||||
_status = FocusTimerStatus.running;
|
||||
_endsAt =
|
||||
snapshot.endsAt ??
|
||||
DateTime.now().add(Duration(seconds: _remainingSeconds));
|
||||
_refreshRemainingTime();
|
||||
if (_remainingSeconds <= 0) {
|
||||
unawaited(_complete());
|
||||
} else {
|
||||
_startTicker();
|
||||
}
|
||||
break;
|
||||
case FocusTimerSnapshotStatus.paused:
|
||||
_status = FocusTimerStatus.paused;
|
||||
break;
|
||||
case FocusTimerSnapshotStatus.completed:
|
||||
_remainingSeconds = 0;
|
||||
_status = FocusTimerStatus.completed;
|
||||
unawaited(_retryCompletedSnapshot(snapshot));
|
||||
break;
|
||||
}
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void selectMinutes(int minutes) {
|
||||
if (isRunning || !presets.contains(minutes)) {
|
||||
return;
|
||||
}
|
||||
_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();
|
||||
unawaited(_persistActiveSession());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
if (!isRunning) {
|
||||
return;
|
||||
}
|
||||
_refreshRemainingTime();
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_status = FocusTimerStatus.paused;
|
||||
unawaited(_persistActiveSession());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_remainingSeconds = _selectedMinutes * 60;
|
||||
_status = FocusTimerStatus.idle;
|
||||
unawaited(_clearSavedSession());
|
||||
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;
|
||||
}
|
||||
if (isRunning) {
|
||||
_refreshRemainingTime();
|
||||
}
|
||||
final totalSeconds = _selectedMinutes * 60;
|
||||
final actualSeconds =
|
||||
_remainingSeconds <= 0
|
||||
? totalSeconds
|
||||
: math.max(1, totalSeconds - _remainingSeconds);
|
||||
_isCompleting = true;
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_remainingSeconds = 0;
|
||||
_status = FocusTimerStatus.completed;
|
||||
unawaited(_persistCompletedSession(actualSeconds));
|
||||
notifyListeners();
|
||||
try {
|
||||
final saved = await _onCompleted(_selectedMinutes, actualSeconds);
|
||||
if (saved) {
|
||||
await _clearSavedSession();
|
||||
}
|
||||
} finally {
|
||||
_isCompleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retryCompletedSnapshot(
|
||||
FocusTimerSessionSnapshot snapshot,
|
||||
) async {
|
||||
if (_isCompleting || snapshot.actualSeconds == null) {
|
||||
return;
|
||||
}
|
||||
_isCompleting = true;
|
||||
try {
|
||||
final saved = await _onCompleted(
|
||||
snapshot.selectedMinutes,
|
||||
snapshot.actualSeconds!,
|
||||
);
|
||||
if (saved) {
|
||||
await _clearSavedSession();
|
||||
}
|
||||
} finally {
|
||||
_isCompleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistActiveSession() async {
|
||||
final sessionStore = _sessionStore;
|
||||
if (sessionStore == null) {
|
||||
return;
|
||||
}
|
||||
if (isRunning) {
|
||||
await sessionStore.save(
|
||||
FocusTimerSessionSnapshot(
|
||||
status: FocusTimerSnapshotStatus.running,
|
||||
selectedMinutes: _selectedMinutes,
|
||||
remainingSeconds: _remainingSeconds,
|
||||
endsAt: _endsAt,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isPaused) {
|
||||
await sessionStore.save(
|
||||
FocusTimerSessionSnapshot(
|
||||
status: FocusTimerSnapshotStatus.paused,
|
||||
selectedMinutes: _selectedMinutes,
|
||||
remainingSeconds: _remainingSeconds,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistCompletedSession(int actualSeconds) async {
|
||||
final sessionStore = _sessionStore;
|
||||
if (sessionStore == null) {
|
||||
return;
|
||||
}
|
||||
await sessionStore.save(
|
||||
FocusTimerSessionSnapshot(
|
||||
status: FocusTimerSnapshotStatus.completed,
|
||||
selectedMinutes: _selectedMinutes,
|
||||
remainingSeconds: 0,
|
||||
actualSeconds: actualSeconds,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clearSavedSession() async {
|
||||
await _sessionStore?.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_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(
|
||||
@@ -41,7 +22,7 @@ List<String> lastSevenDateKeys() {
|
||||
int minutesForDate(List<FocusSession> sessions, String key) {
|
||||
return sessions
|
||||
.where((session) => dateKey(session.completedAt) == key)
|
||||
.fold<int>(0, (total, session) => total + session.minutes);
|
||||
.fold<int>(0, (total, session) => total + session.actualMinutes);
|
||||
}
|
||||
|
||||
int currentStreak(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/appearance_preference.dart';
|
||||
import '../models/language_preference.dart';
|
||||
|
||||
class FirstRunProgress {
|
||||
const FirstRunProgress({
|
||||
required this.onboardingComplete,
|
||||
required this.landingSetupComplete,
|
||||
});
|
||||
|
||||
final bool onboardingComplete;
|
||||
final bool landingSetupComplete;
|
||||
}
|
||||
|
||||
class AppPreferencesStore {
|
||||
static const _appearanceKey = 'focus_seed_appearance';
|
||||
static const _languageKey = 'focus_seed_language';
|
||||
static const _legacyWelcomeCompleteKey = 'focus_seed_welcome_complete';
|
||||
static const _onboardingCompleteKey = 'focus_seed_onboarding_complete';
|
||||
static const _landingSetupCompleteKey = 'focus_seed_landing_setup_complete';
|
||||
|
||||
Future<AppearancePreference> loadAppearance() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
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);
|
||||
}
|
||||
|
||||
Future<FirstRunProgress> loadFirstRunProgress() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final legacyComplete =
|
||||
preferences.getBool(_legacyWelcomeCompleteKey) ?? false;
|
||||
final landingSetupComplete =
|
||||
preferences.getBool(_landingSetupCompleteKey) ?? legacyComplete;
|
||||
final onboardingComplete =
|
||||
preferences.getBool(_onboardingCompleteKey) ??
|
||||
(legacyComplete || landingSetupComplete);
|
||||
return FirstRunProgress(
|
||||
onboardingComplete: onboardingComplete,
|
||||
landingSetupComplete: landingSetupComplete,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveOnboardingComplete() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
await preferences.setBool(_onboardingCompleteKey, true);
|
||||
if (!preferences.containsKey(_landingSetupCompleteKey)) {
|
||||
await preferences.setBool(_landingSetupCompleteKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveLandingSetupComplete() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
await preferences.setBool(_onboardingCompleteKey, true);
|
||||
await preferences.setBool(_landingSetupCompleteKey, true);
|
||||
await preferences.setBool(_legacyWelcomeCompleteKey, true);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,426 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../core/json_utils.dart';
|
||||
import '../core/date_keys.dart' as date_keys;
|
||||
import '../core/focus_durations.dart';
|
||||
import '../models/app_state.dart';
|
||||
import '../models/daily_action.dart';
|
||||
import '../models/focus_session.dart';
|
||||
import '../models/habit.dart';
|
||||
import '../models/mood.dart';
|
||||
import '../models/progress_note.dart';
|
||||
import '../models/reflection_entry.dart';
|
||||
import 'local/focus_seed_database.dart';
|
||||
|
||||
class FocusSeedStore {
|
||||
static const _storageKey = 'focus_seed_state_v1';
|
||||
FocusSeedStore({FocusSeedDatabase? database})
|
||||
: _database = database ?? FocusSeedDatabase();
|
||||
|
||||
Future<AppState> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_storageKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return AppState.starter();
|
||||
}
|
||||
try {
|
||||
return AppState.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
} on Object {
|
||||
return AppState.starter();
|
||||
static const _legacyStorageKey = 'focus_seed_state_v1';
|
||||
static const _legacyMigrationKey = 'focus_seed_state_v1_migrated_to_sqlite';
|
||||
static const _defaultFocusMinutesKey = 'default_focus_minutes';
|
||||
|
||||
final FocusSeedDatabase _database;
|
||||
|
||||
Future<AppState> load({List<String>? defaultHabitTitles}) async {
|
||||
await _migrateLegacyJsonIfNeeded();
|
||||
final snapshot = await _readSnapshot();
|
||||
if (!snapshot.isFreshStart) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
final starter = AppState.starter(defaultHabitTitles: defaultHabitTitles);
|
||||
await save(starter);
|
||||
return starter;
|
||||
}
|
||||
|
||||
Future<void> save(AppState state) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_storageKey, prettyJson(state.toJson()));
|
||||
await _database.transaction(() async {
|
||||
await _database.delete(_database.habitLogRows).go();
|
||||
await _database.delete(_database.progressNoteRows).go();
|
||||
await _database.delete(_database.reflectionRows).go();
|
||||
await _database.delete(_database.focusSessionRows).go();
|
||||
await _database.delete(_database.recentLabelRows).go();
|
||||
await _database.delete(_database.dailyActionRows).go();
|
||||
await _database.delete(_database.habitRows).go();
|
||||
await _database.delete(_database.dailyIntentionRows).go();
|
||||
|
||||
await _database
|
||||
.into(_database.appSettingRows)
|
||||
.insertOnConflictUpdate(
|
||||
AppSettingRowsCompanion.insert(
|
||||
key: _defaultFocusMinutesKey,
|
||||
value: state.defaultFocusMinutes.toString(),
|
||||
),
|
||||
);
|
||||
|
||||
final anchorText = state.anchor.trim();
|
||||
if (anchorText.isNotEmpty) {
|
||||
final now = DateTime.now();
|
||||
await _database
|
||||
.into(_database.dailyIntentionRows)
|
||||
.insert(
|
||||
DailyIntentionRowsCompanion.insert(
|
||||
dateKey: state.anchorDateKey,
|
||||
intentionText: anchorText,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final action in state.actions) {
|
||||
await _database
|
||||
.into(_database.dailyActionRows)
|
||||
.insert(_dailyActionCompanion(action));
|
||||
}
|
||||
|
||||
for (final label in state.recentLabels) {
|
||||
await _database
|
||||
.into(_database.recentLabelRows)
|
||||
.insert(
|
||||
RecentLabelRowsCompanion.insert(
|
||||
label: label,
|
||||
lastUsedAt: DateTime.now(),
|
||||
useCount: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final (index, habit) in state.habits.indexed) {
|
||||
await _database
|
||||
.into(_database.habitRows)
|
||||
.insert(
|
||||
HabitRowsCompanion.insert(
|
||||
id: habit.id,
|
||||
title: habit.title,
|
||||
sortOrder: index,
|
||||
createdAt: habit.createdAt,
|
||||
),
|
||||
);
|
||||
for (final completedDate in habit.completedDates) {
|
||||
await _database
|
||||
.into(_database.habitLogRows)
|
||||
.insert(
|
||||
HabitLogRowsCompanion.insert(
|
||||
id: '${habit.id}-$completedDate',
|
||||
habitId: habit.id,
|
||||
dateKey: completedDate,
|
||||
completedAt: DateTime.parse('$completedDate 12:00:00'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final session in state.sessions) {
|
||||
await _database
|
||||
.into(_database.focusSessionRows)
|
||||
.insert(_focusSessionCompanion(session));
|
||||
}
|
||||
|
||||
for (final note in state.progressNotes) {
|
||||
await _database
|
||||
.into(_database.progressNoteRows)
|
||||
.insert(_progressNoteCompanion(note));
|
||||
}
|
||||
|
||||
for (final reflection in state.reflections) {
|
||||
await _database
|
||||
.into(_database.reflectionRows)
|
||||
.insert(
|
||||
ReflectionRowsCompanion.insert(
|
||||
id: reflection.id,
|
||||
dateKey: reflection.dateKey,
|
||||
mood: reflection.mood.name,
|
||||
note: reflection.note,
|
||||
createdAt: reflection.createdAt,
|
||||
updatedAt: reflection.createdAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
await preferences.setBool(_legacyMigrationKey, true);
|
||||
await preferences.remove(_legacyStorageKey);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_storageKey);
|
||||
await _database.clearProductData();
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
await preferences.remove(_legacyStorageKey);
|
||||
await preferences.setBool(_legacyMigrationKey, true);
|
||||
}
|
||||
|
||||
Future<AppState> _readSnapshot() async {
|
||||
final intentionRows =
|
||||
await (_database.select(_database.dailyIntentionRows)
|
||||
..orderBy([(row) => OrderingTerm.desc(row.dateKey)])
|
||||
..limit(1))
|
||||
.get();
|
||||
final latestIntention = intentionRows.firstOrNull;
|
||||
final dailyActionRows =
|
||||
await (_database.select(_database.dailyActionRows)..orderBy([
|
||||
(row) => OrderingTerm.asc(row.dateKey),
|
||||
(row) => OrderingTerm.asc(row.orderIndex),
|
||||
(row) => OrderingTerm.asc(row.createdAt),
|
||||
])).get();
|
||||
final habitRows =
|
||||
await (_database.select(_database.habitRows)
|
||||
..where((row) => row.archivedAt.isNull())
|
||||
..orderBy([
|
||||
(row) => OrderingTerm.asc(row.sortOrder),
|
||||
(row) => OrderingTerm.asc(row.createdAt),
|
||||
]))
|
||||
.get();
|
||||
final habitLogRows =
|
||||
await (_database.select(_database.habitLogRows)
|
||||
..orderBy([(row) => OrderingTerm.asc(row.dateKey)])).get();
|
||||
final focusSessionRows =
|
||||
await (_database.select(_database.focusSessionRows)
|
||||
..orderBy([(row) => OrderingTerm.asc(row.endedAt)])).get();
|
||||
final progressNoteRows =
|
||||
await (_database.select(_database.progressNoteRows)
|
||||
..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get();
|
||||
final reflectionRows =
|
||||
await (_database.select(_database.reflectionRows)
|
||||
..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get();
|
||||
final recentLabelRows =
|
||||
await (_database.select(_database.recentLabelRows)..orderBy([
|
||||
(row) => OrderingTerm.desc(row.lastUsedAt),
|
||||
(row) => OrderingTerm.desc(row.useCount),
|
||||
])).get();
|
||||
final defaultFocusMinutes = await _readDefaultFocusMinutes();
|
||||
|
||||
final completedDatesByHabit = <String, Set<String>>{};
|
||||
for (final log in habitLogRows) {
|
||||
completedDatesByHabit.putIfAbsent(log.habitId, () => {}).add(log.dateKey);
|
||||
}
|
||||
|
||||
final actions = [
|
||||
for (final action in dailyActionRows) _dailyActionFromRow(action),
|
||||
];
|
||||
final progressNotes = [
|
||||
for (final note in progressNoteRows) _progressNoteFromRow(note),
|
||||
];
|
||||
final anchorDateKey = latestIntention?.dateKey ?? date_keys.todayKey();
|
||||
final anchor = latestIntention?.intentionText ?? '';
|
||||
|
||||
return AppState(
|
||||
anchor: anchor,
|
||||
anchorDateKey: anchorDateKey,
|
||||
habits: [
|
||||
for (final habit in habitRows)
|
||||
Habit(
|
||||
id: habit.id,
|
||||
title: habit.title,
|
||||
createdAt: habit.createdAt,
|
||||
completedDates: completedDatesByHabit[habit.id] ?? const {},
|
||||
),
|
||||
],
|
||||
sessions: [
|
||||
for (final session in focusSessionRows)
|
||||
FocusSession(
|
||||
id: session.id,
|
||||
minutes: session.plannedMinutes,
|
||||
actualSeconds: session.actualSeconds,
|
||||
completedAt: session.endedAt,
|
||||
intention: session.intentionText ?? '',
|
||||
actionId: session.actionId,
|
||||
actionTitleSnapshot:
|
||||
session.actionTitleSnapshot ?? session.intentionText,
|
||||
),
|
||||
],
|
||||
reflections: [
|
||||
for (final reflection in reflectionRows)
|
||||
ReflectionEntry(
|
||||
id: reflection.id,
|
||||
dateKey: reflection.dateKey,
|
||||
note: reflection.note,
|
||||
mood: Mood.values.firstWhere(
|
||||
(mood) => mood.name == reflection.mood,
|
||||
orElse: () => Mood.clear,
|
||||
),
|
||||
createdAt: reflection.createdAt,
|
||||
),
|
||||
],
|
||||
actions: actions,
|
||||
progressNotes: progressNotes,
|
||||
recentLabels: [
|
||||
for (final label in recentLabelRows)
|
||||
if (label.label.trim().isNotEmpty) label.label.trim(),
|
||||
],
|
||||
defaultFocusMinutes: defaultFocusMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> _readDefaultFocusMinutes() async {
|
||||
final row =
|
||||
await (_database.select(_database.appSettingRows)..where(
|
||||
(row) => row.key.equals(_defaultFocusMinutesKey),
|
||||
)).getSingleOrNull();
|
||||
final value = int.tryParse(row?.value ?? '');
|
||||
if (value == null || !isFocusDurationPreset(value)) {
|
||||
return defaultFocusDurationMinutes;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacyJsonIfNeeded() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
if (preferences.getBool(_legacyMigrationKey) ?? false) {
|
||||
return;
|
||||
}
|
||||
if (await _hasDatabaseContent()) {
|
||||
await preferences.setBool(_legacyMigrationKey, true);
|
||||
return;
|
||||
}
|
||||
|
||||
final raw = preferences.getString(_legacyStorageKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
await preferences.setBool(_legacyMigrationKey, true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final legacyState = AppState.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
await save(legacyState);
|
||||
} on Object {
|
||||
await preferences.setBool(_legacyMigrationKey, true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _hasDatabaseContent() async {
|
||||
final action =
|
||||
await (_database.select(_database.dailyActionRows)
|
||||
..limit(1)).getSingleOrNull();
|
||||
if (action != null) {
|
||||
return true;
|
||||
}
|
||||
final intention =
|
||||
await (_database.select(_database.dailyIntentionRows)
|
||||
..limit(1)).getSingleOrNull();
|
||||
if (intention != null) {
|
||||
return true;
|
||||
}
|
||||
final habit =
|
||||
await (_database.select(_database.habitRows)
|
||||
..limit(1)).getSingleOrNull();
|
||||
if (habit != null) {
|
||||
return true;
|
||||
}
|
||||
final session =
|
||||
await (_database.select(_database.focusSessionRows)
|
||||
..limit(1)).getSingleOrNull();
|
||||
if (session != null) {
|
||||
return true;
|
||||
}
|
||||
final progress =
|
||||
await (_database.select(_database.progressNoteRows)
|
||||
..limit(1)).getSingleOrNull();
|
||||
if (progress != null) {
|
||||
return true;
|
||||
}
|
||||
final reflection =
|
||||
await (_database.select(_database.reflectionRows)
|
||||
..limit(1)).getSingleOrNull();
|
||||
return reflection != null;
|
||||
}
|
||||
|
||||
DailyAction _dailyActionFromRow(DailyActionRow row) {
|
||||
return DailyAction(
|
||||
id: row.id,
|
||||
dateKey: row.dateKey,
|
||||
title: row.title,
|
||||
status: dailyActionStatusFromName(row.status),
|
||||
orderIndex: row.orderIndex,
|
||||
labels: _decodeLabels(row.labelsJson),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
archivedAt: row.archivedAt,
|
||||
source: row.source,
|
||||
);
|
||||
}
|
||||
|
||||
ProgressNote _progressNoteFromRow(ProgressNoteRow row) {
|
||||
return ProgressNote(
|
||||
id: row.id,
|
||||
dateKey: row.dateKey,
|
||||
actionId: row.actionId,
|
||||
sessionId: row.sessionId,
|
||||
note: row.note,
|
||||
outcome: progressOutcomeFromName(row.outcome),
|
||||
labels: _decodeLabels(row.labelsJson),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
DailyActionRowsCompanion _dailyActionCompanion(DailyAction action) {
|
||||
return DailyActionRowsCompanion.insert(
|
||||
id: action.id,
|
||||
dateKey: action.dateKey,
|
||||
title: action.title,
|
||||
status: action.status.name,
|
||||
orderIndex: action.orderIndex,
|
||||
labelsJson: jsonEncode(action.labels),
|
||||
createdAt: action.createdAt,
|
||||
updatedAt: action.updatedAt,
|
||||
archivedAt: Value(action.archivedAt),
|
||||
source: action.source,
|
||||
);
|
||||
}
|
||||
|
||||
ProgressNoteRowsCompanion _progressNoteCompanion(ProgressNote note) {
|
||||
return ProgressNoteRowsCompanion.insert(
|
||||
id: note.id,
|
||||
dateKey: note.dateKey,
|
||||
actionId: Value(note.actionId),
|
||||
sessionId: Value(note.sessionId),
|
||||
note: note.note,
|
||||
outcome: Value(note.outcome?.name),
|
||||
labelsJson: jsonEncode(note.labels),
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _decodeLabels(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List<Object?>) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final item in decoded)
|
||||
if (item is String && item.trim().isNotEmpty) item.trim(),
|
||||
];
|
||||
} on Object {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
FocusSessionRowsCompanion _focusSessionCompanion(FocusSession session) {
|
||||
final completedAt = session.completedAt;
|
||||
return FocusSessionRowsCompanion.insert(
|
||||
id: session.id,
|
||||
dateKey: date_keys.dateKey(completedAt),
|
||||
actionId: Value(session.actionId),
|
||||
actionTitleSnapshot: Value(
|
||||
session.actionTitle.trim().isEmpty ? null : session.actionTitle,
|
||||
),
|
||||
intentionText: Value(
|
||||
session.actionTitle.trim().isEmpty ? null : session.actionTitle,
|
||||
),
|
||||
plannedMinutes: session.minutes,
|
||||
actualSeconds: session.actualSeconds,
|
||||
startedAt: completedAt.subtract(Duration(seconds: session.actualSeconds)),
|
||||
endedAt: completedAt,
|
||||
status: 'completed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
part 'focus_seed_database.g.dart';
|
||||
|
||||
class DailyIntentionRows extends Table {
|
||||
@override
|
||||
String get tableName => 'daily_intentions';
|
||||
|
||||
TextColumn get dateKey => text()();
|
||||
TextColumn get intentionText => text().named('text')();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {dateKey};
|
||||
}
|
||||
|
||||
class FocusSessionRows extends Table {
|
||||
@override
|
||||
String get tableName => 'focus_sessions';
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get dateKey => text()();
|
||||
TextColumn get actionId => text().nullable().named('action_id')();
|
||||
TextColumn get actionTitleSnapshot =>
|
||||
text().nullable().named('action_title_snapshot')();
|
||||
TextColumn get intentionText => text().nullable().named('intention_text')();
|
||||
IntColumn get plannedMinutes => integer()();
|
||||
IntColumn get actualSeconds => integer()();
|
||||
DateTimeColumn get startedAt => dateTime()();
|
||||
DateTimeColumn get endedAt => dateTime()();
|
||||
TextColumn get status => text()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class DailyActionRows extends Table {
|
||||
@override
|
||||
String get tableName => 'daily_actions';
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get dateKey => text()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get status => text()();
|
||||
IntColumn get orderIndex => integer().named('order_index')();
|
||||
TextColumn get labelsJson => text().named('labels_json')();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get archivedAt => dateTime().nullable()();
|
||||
TextColumn get source => text()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class ProgressNoteRows extends Table {
|
||||
@override
|
||||
String get tableName => 'progress_notes';
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get dateKey => text()();
|
||||
TextColumn get actionId => text().nullable().named('action_id')();
|
||||
TextColumn get sessionId => text().nullable().named('session_id')();
|
||||
TextColumn get note => text()();
|
||||
TextColumn get outcome => text().nullable()();
|
||||
TextColumn get labelsJson => text().named('labels_json')();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class RecentLabelRows extends Table {
|
||||
@override
|
||||
String get tableName => 'recent_labels';
|
||||
|
||||
TextColumn get label => text()();
|
||||
DateTimeColumn get lastUsedAt => dateTime()();
|
||||
IntColumn get useCount => integer()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {label};
|
||||
}
|
||||
|
||||
class HabitRows extends Table {
|
||||
@override
|
||||
String get tableName => 'habits';
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get title => text()();
|
||||
IntColumn get sortOrder => integer()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get archivedAt => dateTime().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class HabitLogRows extends Table {
|
||||
@override
|
||||
String get tableName => 'habit_logs';
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get habitId => text()();
|
||||
TextColumn get dateKey => text()();
|
||||
DateTimeColumn get completedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class ReflectionRows extends Table {
|
||||
@override
|
||||
String get tableName => 'reflections';
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get dateKey => text()();
|
||||
TextColumn get mood => text()();
|
||||
TextColumn get note => text()();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class AppSettingRows extends Table {
|
||||
@override
|
||||
String get tableName => 'app_settings';
|
||||
|
||||
TextColumn get key => text()();
|
||||
TextColumn get value => text()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {key};
|
||||
}
|
||||
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
DailyIntentionRows,
|
||||
DailyActionRows,
|
||||
FocusSessionRows,
|
||||
ProgressNoteRows,
|
||||
RecentLabelRows,
|
||||
HabitRows,
|
||||
HabitLogRows,
|
||||
ReflectionRows,
|
||||
AppSettingRows,
|
||||
],
|
||||
)
|
||||
class FocusSeedDatabase extends _$FocusSeedDatabase {
|
||||
FocusSeedDatabase([QueryExecutor? executor])
|
||||
: super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 5;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
return MigrationStrategy(
|
||||
onUpgrade: (migrator, from, to) async {
|
||||
if (from < 3) {
|
||||
await customStatement('DROP TABLE IF EXISTS focus_plans');
|
||||
await customStatement('DROP TABLE IF EXISTS preference_tags');
|
||||
}
|
||||
if (from < 4) {
|
||||
await customStatement(
|
||||
'ALTER TABLE focus_sessions ADD COLUMN intention_text TEXT NULL',
|
||||
);
|
||||
}
|
||||
if (from < 5) {
|
||||
await migrator.createTable(dailyActionRows);
|
||||
await migrator.createTable(progressNoteRows);
|
||||
await migrator.createTable(recentLabelRows);
|
||||
await migrator.addColumn(focusSessionRows, focusSessionRows.actionId);
|
||||
await migrator.addColumn(
|
||||
focusSessionRows,
|
||||
focusSessionRows.actionTitleSnapshot,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearProductData() async {
|
||||
await transaction(() async {
|
||||
await delete(habitLogRows).go();
|
||||
await delete(progressNoteRows).go();
|
||||
await delete(reflectionRows).go();
|
||||
await delete(focusSessionRows).go();
|
||||
await delete(recentLabelRows).go();
|
||||
await delete(dailyActionRows).go();
|
||||
await delete(habitRows).go();
|
||||
await delete(dailyIntentionRows).go();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final file = File(path.join(directory.path, 'focus_seed.sqlite'));
|
||||
return NativeDatabase.createInBackground(file);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../application/focus_seed_controller.dart';
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../widgets/app_bottom_sheet.dart';
|
||||
import '../../widgets/app_empty_state.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../../widgets/loading_view.dart';
|
||||
import '../today/widgets/compact_habit_row.dart';
|
||||
|
||||
Future<void> showHabitManagerSheet(
|
||||
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;
|
||||
final l10n = context.l10n;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
if (state == null) {
|
||||
return AppBottomSheetScaffold(
|
||||
title: l10n.habitsTitle,
|
||||
subtitle: l10n.habitsSubtitle,
|
||||
icon: Icons.add_task_rounded,
|
||||
accent: visuals.habit,
|
||||
expand: true,
|
||||
child: const LoadingView(),
|
||||
);
|
||||
}
|
||||
final today = todayKey();
|
||||
return AppBottomSheetScaffold(
|
||||
title: l10n.habitsTitle,
|
||||
subtitle: l10n.habitsSubtitle,
|
||||
icon: Icons.add_task_rounded,
|
||||
accent: visuals.habit,
|
||||
expand: true,
|
||||
trailing: IconButton.filledTonal(
|
||||
tooltip: l10n.addHabit,
|
||||
onPressed:
|
||||
() => showHabitDialog(context, controller.addHabit),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
child:
|
||||
state.habits.isEmpty
|
||||
? _EmptyHabits(
|
||||
onAdd:
|
||||
() =>
|
||||
showHabitDialog(context, controller.addHabit),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(0, 2, 0, 24),
|
||||
itemCount: state.habits.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final habit = state.habits[index];
|
||||
return CompactHabitRow(
|
||||
habit: habit,
|
||||
dateKey: today,
|
||||
onToggle: () => controller.toggleHabit(habit),
|
||||
onEdit:
|
||||
() => showHabitDialog(
|
||||
context,
|
||||
(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) {
|
||||
final l10n = context.l10n;
|
||||
return AppEmptyState(
|
||||
title: l10n.emptyHabitsTitle,
|
||||
body: l10n.emptyHabitPrompt,
|
||||
icon: Icons.add_task_rounded,
|
||||
actionLabel: l10n.addHabit,
|
||||
actionIcon: Icons.add_rounded,
|
||||
onAction: onAdd,
|
||||
accent: FocusSeedVisuals.of(context).habit,
|
||||
illustrationHeight: 86,
|
||||
padding: const EdgeInsets.all(28),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,222 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../application/focus_seed_controller.dart';
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../core/iterable_extensions.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../models/mood.dart';
|
||||
import '../../widgets/app_bottom_sheet.dart';
|
||||
import '../../widgets/app_busy_indicator.dart';
|
||||
import '../../widgets/surface_panel.dart';
|
||||
|
||||
Future<void> showReflectionSheet(
|
||||
BuildContext context,
|
||||
FocusSeedController controller, {
|
||||
String? dateKey,
|
||||
String contextTitle = '',
|
||||
}) {
|
||||
final state = controller.state;
|
||||
if (state == null) {
|
||||
return Future.value();
|
||||
}
|
||||
final targetDateKey = dateKey ?? todayKey();
|
||||
final title = contextTitle.trim();
|
||||
final existingReflection =
|
||||
state.reflections
|
||||
.where((entry) => entry.dateKey == targetDateKey)
|
||||
.firstOrNull;
|
||||
final legacyProgress =
|
||||
state.progressNotes
|
||||
.where((entry) => entry.dateKey == targetDateKey)
|
||||
.firstOrNull;
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder:
|
||||
(context) => _ReflectionComposer(
|
||||
initialNote: existingReflection?.note ?? legacyProgress?.note ?? '',
|
||||
contextTitle: title,
|
||||
dateKey: targetDateKey,
|
||||
onSave:
|
||||
(note) => controller.saveReflectionForDate(
|
||||
targetDateKey,
|
||||
note,
|
||||
existingReflection?.mood ?? Mood.clear,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ReflectionComposer extends StatefulWidget {
|
||||
const _ReflectionComposer({
|
||||
required this.initialNote,
|
||||
required this.contextTitle,
|
||||
required this.dateKey,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final String initialNote;
|
||||
final String contextTitle;
|
||||
final String dateKey;
|
||||
final Future<bool> Function(String note) onSave;
|
||||
|
||||
@override
|
||||
State<_ReflectionComposer> createState() => _ReflectionComposerState();
|
||||
}
|
||||
|
||||
class _ReflectionComposerState extends State<_ReflectionComposer> {
|
||||
late final TextEditingController _controller;
|
||||
bool _saving = false;
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialNote);
|
||||
}
|
||||
|
||||
@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);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (saved) {
|
||||
Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_errorText = l10n.reflectionSaveError;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
return AppBottomSheetScaffold(
|
||||
title: l10n.reflectionSheetTitle,
|
||||
subtitle: l10n.reflectionSheetSubtitle,
|
||||
icon: Icons.edit_note_rounded,
|
||||
accent: visuals.reflection,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.contextTitle.isNotEmpty) ...[
|
||||
SurfacePanel(
|
||||
radius: 18,
|
||||
padding: const EdgeInsets.fromLTRB(12, 11, 12, 11),
|
||||
color: Color.alphaBlend(
|
||||
visuals.action.withValues(alpha: 0.045),
|
||||
scheme.surfaceContainerLowest,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.flag_outlined, color: visuals.action, size: 19),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.todayIntentionLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: visuals.action,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.contextTitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
SurfacePanel(
|
||||
radius: 18,
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
tone: SurfacePanelTone.paper,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
autofocus: widget.initialNote.isEmpty,
|
||||
minLines: 4,
|
||||
maxLines: 7,
|
||||
maxLength: 280,
|
||||
scrollPadding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom + 32,
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
hintText: l10n.reflectionHint,
|
||||
errorText: _errorText,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon:
|
||||
_saving
|
||||
? AppBusyIndicator(semanticLabel: l10n.savingLabel)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
label: Text(_saving ? l10n.savingLabel : l10n.saveReflection),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: visuals.reflection,
|
||||
foregroundColor:
|
||||
ThemeData.estimateBrightnessForColor(visuals.reflection) ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
: scheme.onSurface,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
textStyle: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,433 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../application/focus_seed_controller.dart';
|
||||
import '../../application/focus_timer_controller.dart';
|
||||
import '../../data/focus_seed_store.dart';
|
||||
import '../../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 '../../data/focus_timer_session_store.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../models/appearance_preference.dart';
|
||||
import '../../models/language_preference.dart';
|
||||
import '../../widgets/loading_view.dart';
|
||||
import '../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 '../../widgets/tactile_canvas.dart';
|
||||
import '../focus/focus_screen.dart';
|
||||
import '../me/me_screen.dart';
|
||||
import '../onboarding/landing_setup_screen.dart';
|
||||
import '../records/records_screen.dart';
|
||||
import '../today/today_screen.dart';
|
||||
|
||||
class FocusSeedHome extends StatefulWidget {
|
||||
const FocusSeedHome({super.key});
|
||||
const FocusSeedHome({
|
||||
required this.appearance,
|
||||
required this.onAppearanceChanged,
|
||||
required this.language,
|
||||
required this.showLanguageTools,
|
||||
required this.onLanguageChanged,
|
||||
this.focusSeedStore,
|
||||
this.showLandingSetupInitially = false,
|
||||
this.onLandingSetupComplete,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final AppearancePreference appearance;
|
||||
final ValueChanged<AppearancePreference> onAppearanceChanged;
|
||||
final LanguagePreference language;
|
||||
final bool showLanguageTools;
|
||||
final ValueChanged<LanguagePreference> onLanguageChanged;
|
||||
final FocusSeedStore? focusSeedStore;
|
||||
final bool showLandingSetupInitially;
|
||||
final VoidCallback? onLandingSetupComplete;
|
||||
|
||||
@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;
|
||||
late bool _landingSetupVisible;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
_landingSetupVisible = widget.showLandingSetupInitially;
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final state = await _store.load();
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_controller != null) {
|
||||
return;
|
||||
}
|
||||
_controller = FocusSeedController(
|
||||
store: widget.focusSeedStore,
|
||||
defaultHabitTitles: localizedStarterHabits(context.l10n),
|
||||
);
|
||||
_timerController = FocusTimerController(
|
||||
sessionStore: FocusTimerSessionStore(),
|
||||
onCompleted: (minutes, actualSeconds) async {
|
||||
final saved = await _controller!.completeFocusSession(
|
||||
minutes,
|
||||
actualSeconds: actualSeconds,
|
||||
);
|
||||
if (saved) {
|
||||
await HapticFeedback.mediumImpact();
|
||||
}
|
||||
return saved;
|
||||
},
|
||||
);
|
||||
unawaited(_loadState());
|
||||
}
|
||||
|
||||
Future<void> _loadState() async {
|
||||
final controller = _controller;
|
||||
final timerController = _timerController;
|
||||
if (controller == null || timerController == null) {
|
||||
return;
|
||||
}
|
||||
await controller.load();
|
||||
final defaultMinutes = controller.state?.defaultFocusMinutes;
|
||||
if (!mounted || defaultMinutes == null) {
|
||||
return;
|
||||
}
|
||||
final restored = await timerController.restoreSavedSession();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _state = state);
|
||||
if (restored) {
|
||||
setState(() => _tabIndex = 1);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> _setStateAndSave(AppState state) async {
|
||||
setState(() => _state = state);
|
||||
await _store.save(state);
|
||||
timerController.selectMinutes(defaultMinutes);
|
||||
}
|
||||
|
||||
@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),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded, size: 42),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_messageForError(l10n, controller.error) ??
|
||||
l10n.couldNotOpenApp,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: controller.load,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: Text(l10n.tryAgain),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
void openMe() => setState(() => _tabIndex = 3);
|
||||
void completeLandingSetup({bool openFocus = false}) {
|
||||
widget.onLandingSetupComplete?.call();
|
||||
setState(() {
|
||||
_landingSetupVisible = false;
|
||||
if (openFocus) {
|
||||
_tabIndex = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: timerController,
|
||||
builder: (context, _) {
|
||||
final hideBottomNavigation =
|
||||
!_landingSetupVisible &&
|
||||
_tabIndex == 1 &&
|
||||
(timerController.isRunning || timerController.isPaused);
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness:
|
||||
isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor:
|
||||
theme.navigationBarTheme.backgroundColor ??
|
||||
theme.colorScheme.surface,
|
||||
systemNavigationBarIconBrightness:
|
||||
isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
child: Scaffold(
|
||||
body: TactileCanvas(
|
||||
child:
|
||||
state == null
|
||||
? const LoadingView()
|
||||
_landingSetupVisible
|
||||
? LandingSetupScreen(
|
||||
controller: controller,
|
||||
timerController: timerController,
|
||||
onDone: completeLandingSetup,
|
||||
onStartFocus:
|
||||
() => completeLandingSetup(openFocus: true),
|
||||
)
|
||||
: SafeArea(
|
||||
child: IndexedStack(
|
||||
index: _tabIndex,
|
||||
children: [
|
||||
HomeScreen(
|
||||
state: state,
|
||||
onAnchorChanged:
|
||||
(anchor) => _setStateAndSave(
|
||||
state.copyWith(anchor: anchor.trim()),
|
||||
TodayScreen(
|
||||
controller: controller,
|
||||
timerController: timerController,
|
||||
onOpenSettings: openMe,
|
||||
onOpenFocus:
|
||||
() => setState(() => _tabIndex = 1),
|
||||
),
|
||||
onOpenTimer: () => setState(() => _tabIndex = 1),
|
||||
onToggleHabit: _toggleHabit,
|
||||
onAddHabit: _addHabit,
|
||||
FocusScreen(
|
||||
controller: controller,
|
||||
timerController: timerController,
|
||||
onBackToday:
|
||||
() => setState(() => _tabIndex = 0),
|
||||
),
|
||||
TimerScreen(
|
||||
onComplete: _completeFocusSession,
|
||||
onReflect: () => setState(() => _tabIndex = 3),
|
||||
RecordsScreen(
|
||||
controller: controller,
|
||||
onStartToday:
|
||||
() => setState(() => _tabIndex = 0),
|
||||
),
|
||||
HabitsScreen(
|
||||
state: state,
|
||||
onAddHabit: _addHabit,
|
||||
onRenameHabit: _renameHabit,
|
||||
onDeleteHabit: _deleteHabit,
|
||||
onToggleHabit: _toggleHabit,
|
||||
),
|
||||
ReflectionScreen(state: state, onSave: _saveReflection),
|
||||
SettingsScreen(
|
||||
state: state,
|
||||
onImport: _importState,
|
||||
onClear: _clearData,
|
||||
MeScreen(
|
||||
controller: controller,
|
||||
appearance: widget.appearance,
|
||||
onAppearanceChanged:
|
||||
widget.onAppearanceChanged,
|
||||
language: widget.language,
|
||||
showLanguageTools: widget.showLanguageTools,
|
||||
onLanguageChanged: widget.onLanguageChanged,
|
||||
onDefaultFocusMinutesChanged: (minutes) {
|
||||
if (!timerController.isRunning &&
|
||||
!timerController.isPaused) {
|
||||
timerController.selectMinutes(minutes);
|
||||
}
|
||||
},
|
||||
embedded: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar:
|
||||
state == null
|
||||
_landingSetupVisible || hideBottomNavigation
|
||||
? null
|
||||
: NavigationBar(
|
||||
: _FocusSeedNavigationBar(
|
||||
selectedIndex: _tabIndex,
|
||||
onDestinationSelected: (index) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _tabIndex = index);
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Today',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.timer_outlined),
|
||||
selectedIcon: Icon(Icons.timer),
|
||||
label: 'Timer',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.check_circle_outline),
|
||||
selectedIcon: Icon(Icons.check_circle),
|
||||
label: 'Habits',
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timerController?.dispose();
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FocusSeedNavigationBar extends StatelessWidget {
|
||||
const _FocusSeedNavigationBar({
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
});
|
||||
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final l10n = context.l10n;
|
||||
final destinations = [
|
||||
_FocusSeedNavigationDestination(
|
||||
key: 'today',
|
||||
icon: Icons.home_outlined,
|
||||
label: l10n.navToday,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.edit_note_outlined),
|
||||
selectedIcon: Icon(Icons.edit_note),
|
||||
label: 'Reflect',
|
||||
_FocusSeedNavigationDestination(
|
||||
key: 'focus',
|
||||
icon: Icons.timer_outlined,
|
||||
label: l10n.progressFocus,
|
||||
),
|
||||
_FocusSeedNavigationDestination(
|
||||
key: 'records',
|
||||
icon: Icons.library_books_outlined,
|
||||
label: l10n.navRecords,
|
||||
),
|
||||
_FocusSeedNavigationDestination(
|
||||
key: 'me',
|
||||
icon: Icons.person_outline_rounded,
|
||||
label: l10n.navMe,
|
||||
),
|
||||
];
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerLowest.withValues(alpha: 0.98),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.58),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox(
|
||||
height: 72,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var index = 0; index < destinations.length; index++)
|
||||
Expanded(
|
||||
child: _FocusSeedNavigationItem(
|
||||
destination: destinations[index],
|
||||
selected: index == selectedIndex,
|
||||
onTap: () => onDestinationSelected(index),
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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]));
|
||||
class _FocusSeedNavigationDestination {
|
||||
const _FocusSeedNavigationDestination({
|
||||
required this.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
class _FocusSeedNavigationItem extends StatelessWidget {
|
||||
const _FocusSeedNavigationItem({
|
||||
required this.destination,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
final _FocusSeedNavigationDestination destination;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
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]),
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final selectedColor = visuals.focus;
|
||||
final inactiveColor =
|
||||
isDark ? theme.colorScheme.onSurfaceVariant : const Color(0xFF4B5563);
|
||||
final foregroundColor = selected ? selectedColor : inactiveColor;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: destination.label,
|
||||
child: InkWell(
|
||||
key: ValueKey('focus-seed-nav-${destination.key}'),
|
||||
onTap: onTap,
|
||||
child: SizedBox.expand(
|
||||
child: Stack(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: selected ? 34 : 0,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: selectedColor,
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(3),
|
||||
bottomRight: Radius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(destination.icon, size: 23, color: foregroundColor),
|
||||
const SizedBox(height: 5),
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
style: theme.textTheme.labelMedium!.copyWith(
|
||||
color: foregroundColor,
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
selected ? FontWeight.w800 : FontWeight.w600,
|
||||
letterSpacing: 0,
|
||||
height: 1.1,
|
||||
),
|
||||
child: Text(
|
||||
destination.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveReflection(String note, Mood mood) async {
|
||||
final state = _state;
|
||||
final clean = note.trim();
|
||||
if (state == null || clean.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final today = todayKey();
|
||||
final entry = ReflectionEntry(
|
||||
id: 'reflection-${DateTime.now().microsecondsSinceEpoch}',
|
||||
dateKey: today,
|
||||
note: clean,
|
||||
mood: mood,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
final reflections = [
|
||||
...state.reflections.where((item) => item.dateKey != today),
|
||||
entry,
|
||||
];
|
||||
_analytics.track('reflection_saved');
|
||||
await _setStateAndSave(state.copyWith(reflections: reflections));
|
||||
if (mounted) {
|
||||
showSnack(context, 'Reflection saved');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _importState(String raw) async {
|
||||
try {
|
||||
final imported = AppState.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
await _setStateAndSave(imported);
|
||||
if (mounted) {
|
||||
showSnack(context, 'Data imported');
|
||||
}
|
||||
} on Object {
|
||||
if (mounted) {
|
||||
showSnack(context, 'Import failed. Check the JSON and try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearData() async {
|
||||
await _store.clear();
|
||||
await _setStateAndSave(AppState.starter());
|
||||
if (mounted) {
|
||||
showSnack(context, 'Local data cleared');
|
||||
}
|
||||
}
|
||||
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,742 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../application/focus_seed_controller.dart';
|
||||
import '../../application/focus_timer_controller.dart';
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../models/habit.dart';
|
||||
import '../../models/reflection_entry.dart';
|
||||
import '../../models/today_progress.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../../widgets/experience_motion.dart';
|
||||
import '../../widgets/workbench_ui.dart';
|
||||
import '../habits/habit_manager_sheet.dart';
|
||||
import '../reflection/reflection_sheet.dart';
|
||||
|
||||
class TodayScreen extends StatelessWidget {
|
||||
const TodayScreen({
|
||||
required this.controller,
|
||||
required this.timerController,
|
||||
required this.onOpenSettings,
|
||||
required this.onOpenFocus,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final FocusSeedController controller;
|
||||
final FocusTimerController timerController;
|
||||
final VoidCallback onOpenSettings;
|
||||
final VoidCallback onOpenFocus;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = controller.state!;
|
||||
final l10n = context.l10n;
|
||||
final today = todayKey();
|
||||
final progress = TodayProgress.fromState(state, today);
|
||||
final reflection = _reflectionForToday(state.reflections, today);
|
||||
final activeSession = timerController.isRunning || timerController.isPaused;
|
||||
|
||||
void startFocus() {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
timerController.start();
|
||||
onOpenFocus();
|
||||
}
|
||||
|
||||
void openReflection() {
|
||||
showReflectionSheet(
|
||||
context,
|
||||
controller,
|
||||
dateKey: today,
|
||||
contextTitle: progress.intention,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
WorkbenchTopBar(
|
||||
title: l10n.brandName,
|
||||
trailingIcon: Icons.lock_outline_rounded,
|
||||
trailingTooltip: l10n.privacyTitle,
|
||||
onTrailingPressed: () => showPrivacySheet(context),
|
||||
),
|
||||
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),
|
||||
],
|
||||
StaggeredReveal(
|
||||
child: _TodayCommandPanel(
|
||||
progress: progress,
|
||||
timerController: timerController,
|
||||
activeSession: activeSession,
|
||||
onEditFocus:
|
||||
() => showIntentionDialog(
|
||||
context,
|
||||
controller.setTodayAnchor,
|
||||
initialValue: progress.intention,
|
||||
),
|
||||
onStartFocus: startFocus,
|
||||
onOpenFocus: onOpenFocus,
|
||||
onReflect: openReflection,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
StaggeredReveal(
|
||||
order: 1,
|
||||
child: _HabitWorkbenchSection(
|
||||
habits: state.habits,
|
||||
dateKey: today,
|
||||
onToggle: (habit) => unawaited(controller.toggleHabit(habit)),
|
||||
onAdd: () => showHabitDialog(context, controller.addHabit),
|
||||
onManage: () => showHabitManagerSheet(context, controller),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
StaggeredReveal(
|
||||
order: 2,
|
||||
child: _ReflectionWorkbenchSection(
|
||||
reflection: reflection,
|
||||
onTap: openReflection,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
StaggeredReveal(
|
||||
order: 3,
|
||||
child: _TodayLedgerStrip(progress: progress),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _messageForError(AppLocalizations l10n, FocusSeedError error) {
|
||||
return switch (error) {
|
||||
FocusSeedError.openFailed => l10n.openFailed,
|
||||
FocusSeedError.saveFailed => l10n.saveFailed,
|
||||
};
|
||||
}
|
||||
|
||||
ReflectionEntry? _reflectionForToday(
|
||||
List<ReflectionEntry> reflections,
|
||||
String today,
|
||||
) {
|
||||
final matches =
|
||||
reflections.where((entry) => entry.dateKey == today).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return matches.isEmpty ? null : matches.first;
|
||||
}
|
||||
|
||||
class _TodayCommandPanel extends StatelessWidget {
|
||||
const _TodayCommandPanel({
|
||||
required this.progress,
|
||||
required this.timerController,
|
||||
required this.activeSession,
|
||||
required this.onEditFocus,
|
||||
required this.onStartFocus,
|
||||
required this.onOpenFocus,
|
||||
required this.onReflect,
|
||||
});
|
||||
|
||||
final TodayProgress progress;
|
||||
final FocusTimerController timerController;
|
||||
final bool activeSession;
|
||||
final VoidCallback onEditFocus;
|
||||
final VoidCallback onStartFocus;
|
||||
final VoidCallback onOpenFocus;
|
||||
final VoidCallback onReflect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final title =
|
||||
progress.hasIntention ? progress.intention : l10n.chooseOneOutcome;
|
||||
final status = _todayStatus(context);
|
||||
final primary = _primaryAction(context);
|
||||
return WorkbenchPanel(
|
||||
accent: visuals.action,
|
||||
fill: Color.alphaBlend(
|
||||
visuals.action.withValues(alpha: 0.035),
|
||||
scheme.surfaceContainerLowest,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 15, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
WorkbenchHeader(
|
||||
eyebrow: l10n.todayIntentionLabel,
|
||||
title: title,
|
||||
subtitle: status.$2,
|
||||
icon: activeSession ? Icons.timer_outlined : Icons.flag_outlined,
|
||||
accent: status.$3,
|
||||
trailing: IconButton(
|
||||
tooltip:
|
||||
progress.hasIntention
|
||||
? l10n.editIntentionTitle
|
||||
: l10n.setIntentionTitle,
|
||||
onPressed: onEditFocus,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(40, 40),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(kWorkbenchRadius),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_TodayStepTrack(progress: progress, activeSession: activeSession),
|
||||
const SizedBox(height: 14),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeOutCubic,
|
||||
child: _TodayPrimaryAction(
|
||||
key: ValueKey(primary.$1),
|
||||
label: primary.$2,
|
||||
icon: primary.$3,
|
||||
accent: primary.$4,
|
||||
onPressed: primary.$5,
|
||||
),
|
||||
),
|
||||
if (!progress.hasIntention && !activeSession) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton.icon(
|
||||
onPressed: onEditFocus,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: Text(l10n.setTodaysIntention),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(String, String, Color) _todayStatus(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
if (timerController.isRunning) {
|
||||
return (
|
||||
'running',
|
||||
'${l10n.timerRunning} · ${formatSeconds(timerController.remainingSeconds)}',
|
||||
visuals.focus,
|
||||
);
|
||||
}
|
||||
if (timerController.isPaused) {
|
||||
return (
|
||||
'paused',
|
||||
'${l10n.timerPaused} · ${formatSeconds(timerController.remainingSeconds)}',
|
||||
visuals.habit,
|
||||
);
|
||||
}
|
||||
if (progress.isLoopClosed) {
|
||||
return ('closed', l10n.todayLoopDoneBody, visuals.success);
|
||||
}
|
||||
if (progress.hasFocus && !progress.hasReflection) {
|
||||
return ('reflect', l10n.nextActionReflectBody, visuals.reflection);
|
||||
}
|
||||
if (progress.hasIntention) {
|
||||
return ('ready', l10n.nextActionStartActionBody, visuals.focus);
|
||||
}
|
||||
return ('empty', l10n.todayLoopStartBody, visuals.action);
|
||||
}
|
||||
|
||||
(String, String, IconData, Color, VoidCallback) _primaryAction(
|
||||
BuildContext context,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
if (activeSession) {
|
||||
return (
|
||||
'continue',
|
||||
l10n.continueSession,
|
||||
Icons.arrow_forward_rounded,
|
||||
visuals.focus,
|
||||
onOpenFocus,
|
||||
);
|
||||
}
|
||||
if (progress.hasFocus && !progress.hasReflection) {
|
||||
return (
|
||||
'reflect',
|
||||
l10n.reflectionAction,
|
||||
Icons.edit_note_rounded,
|
||||
visuals.reflection,
|
||||
onReflect,
|
||||
);
|
||||
}
|
||||
return (
|
||||
'start',
|
||||
l10n.startMinutes(timerController.selectedMinutes),
|
||||
Icons.play_arrow_rounded,
|
||||
visuals.focus,
|
||||
onStartFocus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TodayPrimaryAction extends StatelessWidget {
|
||||
const _TodayPrimaryAction({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.accent,
|
||||
required this.onPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color accent;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onPressed,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor:
|
||||
ThemeData.estimateBrightnessForColor(accent) == Brightness.dark
|
||||
? Colors.white
|
||||
: scheme.onSurface,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(kWorkbenchRadius),
|
||||
),
|
||||
),
|
||||
icon: Icon(icon),
|
||||
label: Text(label),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TodayStepTrack extends StatelessWidget {
|
||||
const _TodayStepTrack({required this.progress, required this.activeSession});
|
||||
|
||||
final TodayProgress progress;
|
||||
final bool activeSession;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final items = [
|
||||
_StepData(
|
||||
label: l10n.progressIntention,
|
||||
icon: Icons.flag_outlined,
|
||||
complete: progress.hasIntention,
|
||||
accent: visuals.action,
|
||||
),
|
||||
_StepData(
|
||||
label: l10n.progressFocus,
|
||||
icon: Icons.timer_outlined,
|
||||
complete: progress.hasFocus || activeSession,
|
||||
accent: visuals.focus,
|
||||
),
|
||||
_StepData(
|
||||
label: l10n.progressReflect,
|
||||
icon: Icons.edit_note_rounded,
|
||||
complete: progress.hasReflection,
|
||||
accent: visuals.reflection,
|
||||
),
|
||||
];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
WorkbenchProgressTrack(
|
||||
value: progress.completedSteps / 3,
|
||||
accent: progress.isLoopClosed ? visuals.success : visuals.focus,
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
Row(
|
||||
children: [
|
||||
for (var index = 0; index < items.length; index++) ...[
|
||||
Expanded(child: _StepMarker(data: items[index])),
|
||||
if (index != items.length - 1) const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepData {
|
||||
const _StepData({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.complete,
|
||||
required this.accent,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool complete;
|
||||
final Color accent;
|
||||
}
|
||||
|
||||
class _StepMarker extends StatelessWidget {
|
||||
const _StepMarker({required this.data});
|
||||
|
||||
final _StepData data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final color = data.complete ? data.accent : scheme.outline;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
data.complete
|
||||
? data.accent.withValues(alpha: 0.10)
|
||||
: scheme.surfaceContainerLow.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(kWorkbenchRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
data.complete ? Icons.check_rounded : data.icon,
|
||||
color: color,
|
||||
size: 15,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color:
|
||||
data.complete ? scheme.onSurface : scheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HabitWorkbenchSection extends StatelessWidget {
|
||||
const _HabitWorkbenchSection({
|
||||
required this.habits,
|
||||
required this.dateKey,
|
||||
required this.onToggle,
|
||||
required this.onAdd,
|
||||
required this.onManage,
|
||||
});
|
||||
|
||||
final List<Habit> habits;
|
||||
final String dateKey;
|
||||
final ValueChanged<Habit> onToggle;
|
||||
final VoidCallback onAdd;
|
||||
final VoidCallback onManage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final completed =
|
||||
habits.where((habit) {
|
||||
return habit.completedDates.contains(dateKey);
|
||||
}).length;
|
||||
return WorkbenchPanel(
|
||||
accent: visuals.habit,
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
WorkbenchSectionTitle(
|
||||
title: l10n.todaysHabits,
|
||||
icon: Icons.add_task_rounded,
|
||||
accent: visuals.habit,
|
||||
trailing: WorkbenchPill(
|
||||
label: l10n.completedCount(completed, habits.length),
|
||||
accent: visuals.habit,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (habits.isEmpty)
|
||||
_QuietText(text: l10n.emptyHabitPrompt)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
for (var index = 0; index < habits.length; index++) ...[
|
||||
if (index > 0) const Divider(height: 1),
|
||||
_HabitWorkbenchRow(
|
||||
habit: habits[index],
|
||||
dateKey: dateKey,
|
||||
onToggle: () => onToggle(habits[index]),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(l10n.addHabit),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextButton.icon(
|
||||
onPressed: onManage,
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
label: Text(l10n.manageHabits),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HabitWorkbenchRow extends StatelessWidget {
|
||||
const _HabitWorkbenchRow({
|
||||
required this.habit,
|
||||
required this.dateKey,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
final Habit habit;
|
||||
final String dateKey;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final done = habit.completedDates.contains(dateKey);
|
||||
return WorkbenchListRow(
|
||||
title: habit.title,
|
||||
accent: visuals.habit,
|
||||
leadingIcon:
|
||||
done ? Icons.check_circle_rounded : Icons.radio_button_unchecked,
|
||||
trailing: Checkbox(
|
||||
value: done,
|
||||
activeColor: visuals.habit,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
onChanged: (_) => onToggle(),
|
||||
),
|
||||
onTap: onToggle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReflectionWorkbenchSection extends StatelessWidget {
|
||||
const _ReflectionWorkbenchSection({
|
||||
required this.reflection,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final ReflectionEntry? reflection;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final note = reflection?.note.trim() ?? '';
|
||||
final hasNote = note.isNotEmpty;
|
||||
return WorkbenchPanel(
|
||||
accent: visuals.reflection,
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
WorkbenchSectionTitle(
|
||||
title: l10n.progressReflect,
|
||||
icon: Icons.edit_note_rounded,
|
||||
accent: visuals.reflection,
|
||||
trailing: Icon(
|
||||
hasNote ? Icons.check_rounded : Icons.add_rounded,
|
||||
color: visuals.reflection,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
hasNote ? note : l10n.recordsNoReflection,
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: hasNote ? FontWeight.w800 : FontWeight.w600,
|
||||
height: 1.28,
|
||||
),
|
||||
),
|
||||
if (!hasNote) ...[
|
||||
const SizedBox(height: 10),
|
||||
WorkbenchPill(
|
||||
label: l10n.reflectionAction,
|
||||
icon: Icons.arrow_forward_rounded,
|
||||
accent: visuals.reflection,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TodayLedgerStrip extends StatelessWidget {
|
||||
const _TodayLedgerStrip({required this.progress});
|
||||
|
||||
final TodayProgress progress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
return WorkbenchPanel(
|
||||
accent: visuals.privacy,
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
WorkbenchSectionTitle(
|
||||
title: l10n.todayLoopLabel,
|
||||
icon: Icons.inventory_2_outlined,
|
||||
accent: visuals.privacy,
|
||||
trailing: WorkbenchPill(
|
||||
label: l10n.local,
|
||||
icon: Icons.lock_outline_rounded,
|
||||
accent: visuals.privacy,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact = constraints.maxWidth < 380;
|
||||
final children = [
|
||||
WorkbenchMetricCell(
|
||||
label: l10n.progressFocus,
|
||||
value: l10n.minutesShort(progress.focusMinutes),
|
||||
icon: Icons.timer_outlined,
|
||||
accent: visuals.focus,
|
||||
),
|
||||
WorkbenchMetricCell(
|
||||
label: l10n.progressHabits,
|
||||
value: l10n.completedCount(
|
||||
progress.completedHabits,
|
||||
progress.totalHabits,
|
||||
),
|
||||
icon: Icons.add_task_rounded,
|
||||
accent: visuals.habit,
|
||||
),
|
||||
WorkbenchMetricCell(
|
||||
label: l10n.progressReflect,
|
||||
value:
|
||||
progress.hasReflection
|
||||
? l10n.done
|
||||
: l10n.recordsNoReflection,
|
||||
icon: Icons.edit_note_rounded,
|
||||
accent: visuals.reflection,
|
||||
),
|
||||
];
|
||||
if (compact) {
|
||||
return Column(
|
||||
children: [
|
||||
for (var index = 0; index < children.length; index++) ...[
|
||||
if (index > 0) const SizedBox(height: 8),
|
||||
children[index],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
for (var index = 0; index < children.length; index++) ...[
|
||||
Expanded(child: children[index]),
|
||||
if (index != children.length - 1) const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InlineError extends StatelessWidget {
|
||||
const _InlineError({required this.message});
|
||||
|
||||
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(kWorkbenchRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: scheme.onErrorContainer),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(color: scheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuietText extends StatelessWidget {
|
||||
const _QuietText({required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
height: 1.28,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../app/app_theme.dart';
|
||||
import '../../../l10n/l10n.dart';
|
||||
import '../../../models/habit.dart';
|
||||
import '../../../widgets/app_motion.dart';
|
||||
import '../../../widgets/experience_motion.dart';
|
||||
import '../../../widgets/pressable_scale.dart';
|
||||
|
||||
class CompactHabitRow extends StatelessWidget {
|
||||
const CompactHabitRow({
|
||||
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: PressableScale(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onToggle();
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: AppMotion.duration(context, AppMotion.fast),
|
||||
curve: AppMotion.emphasized,
|
||||
margin: const EdgeInsets.symmetric(vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
complete
|
||||
? FocusSeedVisuals.of(
|
||||
context,
|
||||
).success.withValues(alpha: 0.055)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_HabitCheckControl(complete: complete),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: AppMotion.duration(context, AppMotion.fast),
|
||||
style:
|
||||
theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight:
|
||||
complete ? FontWeight.w700 : FontWeight.w500,
|
||||
) ??
|
||||
const TextStyle(),
|
||||
child: Text(
|
||||
habit.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onEdit != null || onDelete != null)
|
||||
PopupMenuButton<String>(
|
||||
tooltip: l10n.habitOptions,
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
onEdit?.call();
|
||||
} else if (value == 'delete') {
|
||||
onDelete?.call();
|
||||
}
|
||||
},
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Text(l10n.rename),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(l10n.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HabitCheckControl extends StatelessWidget {
|
||||
const _HabitCheckControl({required this.complete});
|
||||
|
||||
final bool complete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
return SizedBox.square(
|
||||
dimension: 32,
|
||||
child: Center(
|
||||
child: AnimatedScale(
|
||||
scale: complete ? 1.02 : 1,
|
||||
duration: AppMotion.duration(context, AppMotion.fast),
|
||||
curve: AppMotion.settle,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow:
|
||||
complete
|
||||
? [
|
||||
BoxShadow(
|
||||
color: visuals.success.withValues(alpha: 0.16),
|
||||
blurRadius: 9,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: [
|
||||
BoxShadow(
|
||||
color: scheme.shadow.withValues(alpha: 0.025),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AnimatedCheckMark(
|
||||
checked: complete,
|
||||
color: visuals.success,
|
||||
size: 25,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../app/app_theme.dart';
|
||||
import '../../../l10n/l10n.dart';
|
||||
import '../../../models/today_progress.dart';
|
||||
import '../../../widgets/app_motion.dart';
|
||||
|
||||
class DailyProgressRail extends StatelessWidget {
|
||||
const DailyProgressRail({required this.progress, super.key});
|
||||
|
||||
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.progressReflect,
|
||||
detail: progress.hasReflection ? l10n.done : l10n.later,
|
||||
icon: Icons.edit_note_rounded,
|
||||
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);
|
||||
final scheme = theme.colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final lastCompleteIndex = items.lastIndexWhere((item) => item.complete);
|
||||
final progress =
|
||||
lastCompleteIndex <= 0 ? 0.0 : lastCompleteIndex / (items.length - 1);
|
||||
return Semantics(
|
||||
label: context.l10n.dailyStepsComplete(
|
||||
items.where((item) => item.complete).length,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final inset = constraints.maxWidth / (items.length * 2);
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned(
|
||||
left: inset,
|
||||
right: inset,
|
||||
top: 15,
|
||||
child: _ProgressTrack(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: inset,
|
||||
right: inset,
|
||||
top: 15,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: progress.clamp(0.0, 1.0),
|
||||
child: _ProgressTrack(
|
||||
color: visuals.success,
|
||||
glow: visuals.success.withValues(alpha: 0.20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in items)
|
||||
Expanded(
|
||||
child: Center(child: _ProgressDot(item: item)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final item in items)
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
item.detail,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 10.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 _ProgressTrack extends StatelessWidget {
|
||||
const _ProgressTrack({required this.color, this.glow});
|
||||
|
||||
final Color color;
|
||||
final Color? glow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: AppMotion.duration(context, AppMotion.medium),
|
||||
curve: AppMotion.emphasized,
|
||||
height: 2,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
boxShadow:
|
||||
glow == null ? null : [BoxShadow(color: glow!, blurRadius: 10)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgressDot extends StatelessWidget {
|
||||
const _ProgressDot({required this.item});
|
||||
|
||||
final _ProgressItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
return AnimatedScale(
|
||||
scale: item.complete ? 1.08 : 1,
|
||||
duration: AppMotion.duration(context, AppMotion.fast),
|
||||
curve: AppMotion.settle,
|
||||
child: AnimatedContainer(
|
||||
duration: AppMotion.duration(context, AppMotion.medium),
|
||||
curve: AppMotion.emphasized,
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: item.complete ? visuals.success : scheme.surfaceContainerLow,
|
||||
border: Border.all(
|
||||
color: item.complete ? visuals.success : scheme.outlineVariant,
|
||||
),
|
||||
boxShadow:
|
||||
item.complete
|
||||
? [
|
||||
BoxShadow(
|
||||
color: visuals.success.withValues(alpha: 0.20),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: AnimatedSwitcher(
|
||||
duration: AppMotion.duration(context, AppMotion.fast),
|
||||
switchInCurve: AppMotion.settle,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(scale: animation, child: child),
|
||||
);
|
||||
},
|
||||
child: Icon(
|
||||
item.complete ? Icons.check_rounded : item.icon,
|
||||
key: ValueKey('${item.label}-${item.complete}'),
|
||||
size: 15,
|
||||
color: item.complete ? scheme.onPrimary : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../app/app_theme.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 theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final completed =
|
||||
habits.where((habit) => habit.completedDates.contains(dateKey)).length;
|
||||
final completion =
|
||||
habits.isEmpty ? 0.0 : (completed / habits.length).clamp(0.0, 1.0);
|
||||
return SurfacePanel(
|
||||
tone: SurfacePanelTone.paper,
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 14, 10),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.todaysHabits,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.completedCount(completed, habits.length),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.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: 12),
|
||||
_HabitCompletionTrack(progress: completion),
|
||||
const SizedBox(height: 6),
|
||||
if (habits.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.eco_outlined, color: visuals.habit),
|
||||
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(indent: 48),
|
||||
CompactHabitRow(
|
||||
habit: habits[index],
|
||||
dateKey: dateKey,
|
||||
onToggle: () => onToggle(habits[index]),
|
||||
),
|
||||
],
|
||||
if (habits.length > 4) ...[
|
||||
const Divider(indent: 48),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: onManage,
|
||||
icon: const Icon(Icons.arrow_forward_rounded, size: 18),
|
||||
label: Text(l10n.viewAllHabits(habits.length)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HabitCompletionTrack extends StatelessWidget {
|
||||
const _HabitCompletionTrack({required this.progress});
|
||||
|
||||
final double progress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
child: SizedBox(
|
||||
height: 5,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ColoredBox(color: scheme.outlineVariant.withValues(alpha: 0.46)),
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: progress),
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, child) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: value,
|
||||
heightFactor: 1,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: visuals.success,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../app/app_theme.dart';
|
||||
import '../../../l10n/l10n.dart';
|
||||
import '../../../models/today_progress.dart';
|
||||
import '../../../widgets/surface_panel.dart';
|
||||
|
||||
class IntentionCard extends StatelessWidget {
|
||||
const IntentionCard({
|
||||
required this.intention,
|
||||
required this.progress,
|
||||
required this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String intention;
|
||||
final TodayProgress progress;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final l10n = context.l10n;
|
||||
final isEmpty = intention.isEmpty;
|
||||
final body =
|
||||
isEmpty
|
||||
? l10n.todayLoopStartBody
|
||||
: progress.completedSteps >= 3
|
||||
? l10n.todayLoopDoneBody
|
||||
: l10n.todayLoopActiveBody;
|
||||
return SurfacePanel(
|
||||
elevated: !isEmpty,
|
||||
tone: SurfacePanelTone.paper,
|
||||
onTap: onEdit,
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 15),
|
||||
color: Color.alphaBlend(
|
||||
visuals.action.withValues(alpha: isEmpty ? 0.035 : 0.025),
|
||||
theme.colorScheme.surfaceContainerLowest,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 5,
|
||||
height: 58,
|
||||
decoration: BoxDecoration(
|
||||
color: visuals.action,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.24),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.todayIntentionLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: visuals.action,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
isEmpty ? l10n.chooseOneOutcome : intention,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.08,
|
||||
color:
|
||||
isEmpty
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
body,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (isEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.flag_outlined),
|
||||
label: Text(l10n.setTodaysIntention),
|
||||
),
|
||||
],
|
||||
if (!isEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_StatusChip(
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
label: l10n.todayLoopProgress(progress.completedSteps),
|
||||
color: visuals.success,
|
||||
),
|
||||
_StatusChip(
|
||||
icon: Icons.timer_outlined,
|
||||
label: l10n.minutesShort(
|
||||
progress.focusMinutes == 0 ? 10 : progress.focusMinutes,
|
||||
),
|
||||
color: visuals.focus,
|
||||
),
|
||||
_StatusChip(
|
||||
icon: Icons.edit_note_rounded,
|
||||
label:
|
||||
progress.hasReflection
|
||||
? l10n.reflectionCaptured
|
||||
: l10n.reflectionAction,
|
||||
color: visuals.reflection,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 178),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
tone: SurfacePanelTone.paper,
|
||||
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,349 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"appTitle": "Focus Seed",
|
||||
"brandName": "Focus Seed",
|
||||
"saving": "Saving",
|
||||
"local": "Local",
|
||||
"navToday": "Today",
|
||||
"navRecords": "Records",
|
||||
"navMe": "Me",
|
||||
"tryAgain": "Try again",
|
||||
"couldNotOpenApp": "Could not open Focus Seed.",
|
||||
"loadingLocalData": "Loading local data",
|
||||
"openFailed": "Your local data could not be opened.",
|
||||
"saveFailed": "That change could not be saved. Please try again.",
|
||||
"goodMorning": "Good morning",
|
||||
"goodDay": "Good day",
|
||||
"goodEvening": "Good evening",
|
||||
"goodNight": "Good night",
|
||||
"readableDate": "{date} - {weekday}",
|
||||
"@readableDate": {
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "Object"
|
||||
},
|
||||
"weekday": {
|
||||
"type": "Object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"todayIntentionLabel": "TODAY'S FOCUS",
|
||||
"chooseOneOutcome": "Name today's focus",
|
||||
"focusLabel": "FOCUS",
|
||||
"timerReady": "Ready when you are",
|
||||
"timerRunning": "Deep work in progress",
|
||||
"timerPaused": "Paused - your place is kept",
|
||||
"timerCompleted": "Session complete",
|
||||
"focusCurrentIntention": "Today's focus: {intention}",
|
||||
"@focusCurrentIntention": {
|
||||
"placeholders": {
|
||||
"intention": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"focusNoIntentionTitle": "Start this session first",
|
||||
"reflectOneMinute": "Write progress note",
|
||||
"startFocus": "Start focus",
|
||||
"startAnotherSession": "Start another session",
|
||||
"setTodaysIntention": "Set today's focus",
|
||||
"pause": "Pause",
|
||||
"end": "End",
|
||||
"completeSession": "Complete session",
|
||||
"continueSession": "Continue session",
|
||||
"resume": "Resume",
|
||||
"startMinutes": "Start {minutes} min",
|
||||
"@startMinutes": {
|
||||
"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": "Keep this optional and small.",
|
||||
"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}/3 daily steps complete",
|
||||
"@dailyStepsComplete": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"habitOptions": "Habit options",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"habitsTitle": "Supporting habits",
|
||||
"habitsSubtitle": "Optional repeatable steps. Today's focus still comes first.",
|
||||
"emptyHabitsTitle": "Add a supporting habit",
|
||||
"addHabitTitle": "Add habit",
|
||||
"renameHabitTitle": "Rename habit",
|
||||
"habitHint": "Example: Read 10 pages",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"textEntryRequired": "Write a short line first.",
|
||||
"setIntentionTitle": "Set today's focus",
|
||||
"editIntentionTitle": "Edit today's focus",
|
||||
"intentionHint": "What matters most today?",
|
||||
"deleteHabitTitle": "Delete habit?",
|
||||
"deleteHabitBody": "Past completion records for this habit will be removed.",
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
"clearTodayDataTitle": "Clear today?",
|
||||
"clearTodayDataBody": "This resets today's focus, habit checks, focus sessions, and reflection. Past days stay untouched.",
|
||||
"clearAllDataTitle": "Delete all local data?",
|
||||
"clearAllDataBody": "This deletes intentions, habits, focus sessions, and reflections on this device. It cannot be undone.",
|
||||
"clear": "Clear",
|
||||
"deleteAll": "Delete all",
|
||||
"privacyTitle": "Privacy & data",
|
||||
"privacyBodyOne": "Focus Seed stores your intention, habits, focus sessions, reflections, and settings locally on this device. This version has no sign-in, cloud sync, ads, analytics SDK, or billing SDK.",
|
||||
"privacyBodyTwo": "You can clear today's progress or delete all local data from settings at any time.",
|
||||
"reflectionEmptyError": "Write one honest sentence.",
|
||||
"reflectionSaveError": "Could not save. Please try again.",
|
||||
"reflectionSheetTitle": "Leave one progress note",
|
||||
"reflectionSheetSubtitle": "Save what moved forward so next time has a clear start.",
|
||||
"reflectionHint": "What moved forward?",
|
||||
"reflectionMoodLabel": "Mood is optional",
|
||||
"savingLabel": "Saving",
|
||||
"saveReflection": "Save note",
|
||||
"moodCalm": "Calm",
|
||||
"moodClear": "Clear",
|
||||
"moodProud": "Proud",
|
||||
"moodTired": "Tired",
|
||||
"meTitle": "Me",
|
||||
"meSubtitle": "Manage your rhythm, appearance, and records.",
|
||||
"appSettingsTitle": "App settings",
|
||||
"appearance": "Theme",
|
||||
"system": "System",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"language": "Language",
|
||||
"developmentOnly": "Development only",
|
||||
"languageDefault": "Default",
|
||||
"languageEnglish": "English",
|
||||
"languageChinese": "Chinese",
|
||||
"preferencesTitle": "Preferences",
|
||||
"preferencesDetail": "Theme, language, and default focus",
|
||||
"defaultFocusDuration": "Default focus",
|
||||
"defaultFocusDurationDetail": "Next session",
|
||||
"localData": "Local records",
|
||||
"localDataDetail": "Private on device",
|
||||
"personalGardenTitle": "My accumulation",
|
||||
"personalGardenSubtitle": "Your local action records, progress notes, and app controls stay on this device.",
|
||||
"focusDataLabel": "Focus",
|
||||
"habitsDataLabel": "Habits",
|
||||
"notesDataLabel": "Progress",
|
||||
"totalActionTimeLabel": "Action time",
|
||||
"recordsCountLabel": "Records",
|
||||
"progressNotesCountLabel": "Progress notes",
|
||||
"clearToday": "Clear today",
|
||||
"clearTodaySubtitle": "Reset only today's loop.",
|
||||
"privacySubtitle": "No account or cloud sync.",
|
||||
"deleteAllLocalData": "Delete all data",
|
||||
"deleteAllLocalDataSubtitle": "Remove this device's Focus Seed records.",
|
||||
"todayDataCleared": "Today's data cleared",
|
||||
"localDataCleared": "Local data deleted",
|
||||
"todayLoopLabel": "TODAY'S LOOP",
|
||||
"todayLoopStartTitle": "Start with one clear focus",
|
||||
"todayLoopStartBody": "Pick the one result that matters today.",
|
||||
"todayLoopActiveTitle": "Your day is taking shape",
|
||||
"todayLoopActiveBody": "Focus once, then leave one useful note.",
|
||||
"todayLoopDoneTitle": "Progress saved",
|
||||
"todayLoopDoneBody": "You moved today forward and left a clear place to continue.",
|
||||
"todayLoopProgress": "{completed}/3 done",
|
||||
"@todayLoopProgress": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewNow": "Write a note",
|
||||
"weekFocusLabel": "Focus",
|
||||
"streakLabel": "Streak",
|
||||
"activeDaysLabel": "Active",
|
||||
"habitRateLabel": "Habits",
|
||||
"daysValue": "{count}d",
|
||||
"@daysValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activeDaysValue": "{count}/7",
|
||||
"@activeDaysValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewHistory": "Record library",
|
||||
"viewHistorySubtitle": "Open focus sessions and reflections.",
|
||||
"historyTitle": "History",
|
||||
"focusHistory": "Focus",
|
||||
"notesHistory": "Notes",
|
||||
"emptyFocusHistoryTitle": "No focus sessions yet",
|
||||
"emptyFocusHistoryBody": "Finish one focus session and it will appear here.",
|
||||
"emptyNotesHistoryTitle": "No notes yet",
|
||||
"emptyNotesHistoryBody": "Save one reflection after a session and it will appear here.",
|
||||
"recordsEmptyTitle": "No progress records yet",
|
||||
"recordsEmptyBody": "Finish one focus session and leave a note to keep today's progress here.",
|
||||
"recordsCalendarTitle": "Activity dates",
|
||||
"recordsDateLedgerTitle": "Date ledger",
|
||||
"backToToday": "Back to Today",
|
||||
"recordsNoIntention": "Unnamed daily focus",
|
||||
"recordsNoReflection": "No progress note yet",
|
||||
"recordsMissingProgressHelper": "Add one line so this is easier to pick up later.",
|
||||
"addProgressNote": "Add progress note",
|
||||
"continueThisAction": "Continue this",
|
||||
"replaceTodayActionTitle": "Replace today's action?",
|
||||
"replaceTodayActionBody": "This will change today's action to \"{actionTitle}\". Existing records will stay.",
|
||||
"@replaceTodayActionBody": {
|
||||
"placeholders": {
|
||||
"actionTitle": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recentFocus": "Recent focus",
|
||||
"sessionsTotal": "{count} sessions total",
|
||||
"@sessionsTotal": {
|
||||
"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": "Write one sentence to close today",
|
||||
"nextActionTitle": "Next action",
|
||||
"nextActionSetFocusTitle": "Write today's focus",
|
||||
"nextActionSetFocusBody": "One short line is enough. You can adjust it later.",
|
||||
"nextActionStartFocusTitle": "Start 10 minutes",
|
||||
"nextActionStartFocusBody": "Writing the action is optional. Start first.",
|
||||
"nextActionStartActionBody": "Move this forward in one small session, then leave one progress note.",
|
||||
"nextActionHabitsTitle": "Finish one small habit",
|
||||
"nextActionHabitsBody": "Complete one repeatable step while it is still easy.",
|
||||
"nextActionReflectTitle": "Write a quick note",
|
||||
"nextActionReflectBody": "Record what moved forward so the next start is easier.",
|
||||
"nextActionCompleteTitle": "Today moved forward",
|
||||
"nextActionCompleteBody": "Your note is saved. Continue only if it still feels useful.",
|
||||
"nextActionContinueFocus": "Focus {minutes} min again",
|
||||
"@nextActionContinueFocus": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"habitTemplatesTitle": "Useful starts",
|
||||
"habitTemplatesBody": "Add one tiny repeatable action.",
|
||||
"habitTemplateStretch": "2 min stretch",
|
||||
"habitTemplateRead": "Read 10 pages",
|
||||
"habitTemplateWalk": "Walk outside",
|
||||
"habitTemplateNoScroll": "No-scroll lunch",
|
||||
"languageDetail": "Interface language",
|
||||
"recordsAndPrivacyTitle": "Records & privacy",
|
||||
"recordsAndPrivacyBody": "Your history stays local. Keep, reset, or delete it when needed.",
|
||||
"welcomeTrustLabel": "TODAY",
|
||||
"welcomeTitle": "Plant just one thing each day.",
|
||||
"welcomeBody": "Start with today's most important thing.",
|
||||
"welcomeNext": "The next step starts here.",
|
||||
"onboardingFocusBadge": "FOCUS",
|
||||
"onboardingFocusTitle": "Protect 10 minutes first.",
|
||||
"onboardingFocusBody": "It does not have to be long. Starting creates progress.",
|
||||
"onboardingFocusNext": "Keep this one thing in front of you while you focus.",
|
||||
"onboardingReflectBadge": "NOTE",
|
||||
"onboardingReflectTitle": "Leave one progress note.",
|
||||
"onboardingReflectBody": "When you finish, write one line so you know where to continue next time.",
|
||||
"onboardingReflectNext": "Now set today's focus.",
|
||||
"onboardingNext": "Continue",
|
||||
"welcomeStart": "Set today's focus",
|
||||
"welcomeSkip": "Skip",
|
||||
"landingTitle": "Start with 10 minutes.",
|
||||
"landingBody": "Writing is optional. Start first.",
|
||||
"landingFocusLabel": "What do you want to do first?",
|
||||
"landingFocusEntry": "Write down what you will do",
|
||||
"landingFocusHint": "Example: finish the launch checklist",
|
||||
"customFocusDuration": "Custom length",
|
||||
"customFocusDurationHint": "Enter 1-180 minutes",
|
||||
"minutesInputLabel": "Minutes",
|
||||
"landingStart": "Start 10 minutes",
|
||||
"landingSkip": "Enter Today",
|
||||
"defaultHabitPlanTask": "Plan one clear task",
|
||||
"defaultHabitFocus": "Focus for 10 minutes",
|
||||
"defaultHabitReflect": "Write one-line reflection"
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
{
|
||||
"@@locale": "zh",
|
||||
"appTitle": "Focus Seed",
|
||||
"brandName": "Focus Seed",
|
||||
"saving": "保存中",
|
||||
"local": "本地",
|
||||
"navToday": "今日",
|
||||
"navRecords": "记录",
|
||||
"navMe": "我的",
|
||||
"tryAgain": "重试",
|
||||
"couldNotOpenApp": "无法打开 Focus Seed。",
|
||||
"loadingLocalData": "正在读取本地数据",
|
||||
"openFailed": "本地数据打开失败。",
|
||||
"saveFailed": "保存失败,请重试。",
|
||||
"goodMorning": "早上好",
|
||||
"goodDay": "今天好",
|
||||
"goodEvening": "晚上好",
|
||||
"goodNight": "夜深了",
|
||||
"readableDate": "{date} - {weekday}",
|
||||
"@readableDate": {
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "Object"
|
||||
},
|
||||
"weekday": {
|
||||
"type": "Object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"todayIntentionLabel": "今日重点",
|
||||
"chooseOneOutcome": "写下今天的重点",
|
||||
"focusLabel": "专注",
|
||||
"timerReady": "准备好了就开始",
|
||||
"timerRunning": "专注中",
|
||||
"timerPaused": "已暂停",
|
||||
"timerCompleted": "已完成",
|
||||
"focusCurrentIntention": "今日重点:{intention}",
|
||||
"@focusCurrentIntention": {
|
||||
"placeholders": {
|
||||
"intention": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"focusNoIntentionTitle": "先开始这一段",
|
||||
"reflectOneMinute": "写一句进展",
|
||||
"startFocus": "开始专注",
|
||||
"startAnotherSession": "再来一轮",
|
||||
"setTodaysIntention": "设置今日重点",
|
||||
"pause": "暂停",
|
||||
"end": "结束",
|
||||
"completeSession": "完成本轮",
|
||||
"continueSession": "继续本轮",
|
||||
"resume": "继续",
|
||||
"startMinutes": "开始 {minutes} 分钟",
|
||||
"@startMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"resetTimer": "重置计时",
|
||||
"todaysHabits": "今日习惯",
|
||||
"completedCount": "{completed}/{total} 完成",
|
||||
"@completedCount": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"manageHabits": "管理习惯",
|
||||
"addHabit": "添加习惯",
|
||||
"add": "添加",
|
||||
"emptyHabitPrompt": "保持可选,也保持很小。",
|
||||
"viewAllHabits": "查看全部 {count} 个",
|
||||
"@viewAllHabits": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reflectionSaved": "复盘已保存",
|
||||
"reflectionPromptTitle": "复盘一下",
|
||||
"reflectionPromptBody": "写一句有用的总结。",
|
||||
"progressIntention": "目标",
|
||||
"progressFocus": "专注",
|
||||
"progressHabits": "习惯",
|
||||
"progressReflect": "复盘",
|
||||
"done": "完成",
|
||||
"next": "下一步",
|
||||
"later": "稍后",
|
||||
"minutesShort": "{minutes} 分钟",
|
||||
"@minutesShort": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dailyStepsComplete": "今日完成 {completed}/3",
|
||||
"@dailyStepsComplete": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"habitOptions": "习惯选项",
|
||||
"rename": "重命名",
|
||||
"delete": "删除",
|
||||
"habitsTitle": "辅助习惯",
|
||||
"habitsSubtitle": "可选的重复动作,今日重点仍然放在前面。",
|
||||
"emptyHabitsTitle": "添加一个辅助习惯",
|
||||
"addHabitTitle": "添加习惯",
|
||||
"renameHabitTitle": "重命名习惯",
|
||||
"habitHint": "例如:读 10 页书",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"textEntryRequired": "先写一个短句。",
|
||||
"setIntentionTitle": "设置今日重点",
|
||||
"editIntentionTitle": "编辑今日重点",
|
||||
"intentionHint": "今天最重要的是什么?",
|
||||
"deleteHabitTitle": "删除习惯?",
|
||||
"deleteHabitBody": "这个习惯的历史完成记录也会删除。",
|
||||
"close": "关闭",
|
||||
"copy": "复制",
|
||||
"clearTodayDataTitle": "清理今日数据?",
|
||||
"clearTodayDataBody": "只重置今天的重点、打卡、专注记录和复盘,过去日期的数据会保留。",
|
||||
"clearAllDataTitle": "删除全部本地数据?",
|
||||
"clearAllDataBody": "会删除本机的目标、习惯、专注记录和复盘,无法撤销。",
|
||||
"clear": "清除",
|
||||
"deleteAll": "全部删除",
|
||||
"privacyTitle": "隐私与数据",
|
||||
"privacyBodyOne": "Focus Seed 只在本机保存目标、习惯、专注记录、复盘和设置。当前版本没有登录、云同步、广告、分析 SDK 或付费 SDK。",
|
||||
"privacyBodyTwo": "你可以随时在设置中清理今日进度,或删除本机的全部本地数据。",
|
||||
"reflectionEmptyError": "写一句真实的总结。",
|
||||
"reflectionSaveError": "保存失败,请重试。",
|
||||
"reflectionSheetTitle": "留下一句进展",
|
||||
"reflectionSheetSubtitle": "记下刚推进了什么,下次就知道从哪里继续。",
|
||||
"reflectionHint": "刚才推进了什么?",
|
||||
"reflectionMoodLabel": "状态可以稍后补",
|
||||
"savingLabel": "保存中",
|
||||
"saveReflection": "保存记录",
|
||||
"moodCalm": "平静",
|
||||
"moodClear": "清醒",
|
||||
"moodProud": "满意",
|
||||
"moodTired": "疲惫",
|
||||
"meTitle": "我的",
|
||||
"meSubtitle": "管理你的节奏、外观和记录。",
|
||||
"appSettingsTitle": "应用设置",
|
||||
"appearance": "主题",
|
||||
"system": "系统",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"language": "语言",
|
||||
"developmentOnly": "仅开发环境",
|
||||
"languageDefault": "默认",
|
||||
"languageEnglish": "English",
|
||||
"languageChinese": "中文",
|
||||
"preferencesTitle": "偏好",
|
||||
"preferencesDetail": "主题、语言和默认专注",
|
||||
"defaultFocusDuration": "默认专注",
|
||||
"defaultFocusDurationDetail": "下一轮",
|
||||
"localData": "本地记录",
|
||||
"localDataDetail": "只在本机",
|
||||
"personalGardenTitle": "我的累计",
|
||||
"personalGardenSubtitle": "你的本地行动记录、进展和应用控制都保留在这台设备上。",
|
||||
"focusDataLabel": "专注",
|
||||
"habitsDataLabel": "习惯",
|
||||
"notesDataLabel": "进展",
|
||||
"totalActionTimeLabel": "行动时间",
|
||||
"recordsCountLabel": "记录",
|
||||
"progressNotesCountLabel": "进展",
|
||||
"clearToday": "清理今天",
|
||||
"clearTodaySubtitle": "只重置今天的闭环。",
|
||||
"privacySubtitle": "无账号,无云同步。",
|
||||
"deleteAllLocalData": "删除全部数据",
|
||||
"deleteAllLocalDataSubtitle": "移除这台设备上的 Focus Seed 记录。",
|
||||
"todayDataCleared": "今日数据已清理",
|
||||
"localDataCleared": "本地数据已删除",
|
||||
"todayLoopLabel": "今日闭环",
|
||||
"todayLoopStartTitle": "先定下一个重点",
|
||||
"todayLoopStartBody": "先写下今天最重要的一件事。",
|
||||
"todayLoopActiveTitle": "今天正在成形",
|
||||
"todayLoopActiveBody": "先完成一轮专注,再留一句有用的记录。",
|
||||
"todayLoopDoneTitle": "进展已保存",
|
||||
"todayLoopDoneBody": "今天已经推进了一点,下次也知道从哪里继续。",
|
||||
"todayLoopProgress": "已完成 {completed}/3",
|
||||
"@todayLoopProgress": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewNow": "写一句记录",
|
||||
"weekFocusLabel": "专注",
|
||||
"streakLabel": "连续",
|
||||
"activeDaysLabel": "活跃",
|
||||
"habitRateLabel": "习惯",
|
||||
"daysValue": "{count}天",
|
||||
"@daysValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activeDaysValue": "{count}/7",
|
||||
"@activeDaysValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewHistory": "记录库",
|
||||
"viewHistorySubtitle": "查看专注记录和复盘。",
|
||||
"historyTitle": "历史",
|
||||
"focusHistory": "专注",
|
||||
"notesHistory": "复盘",
|
||||
"emptyFocusHistoryTitle": "还没有专注记录",
|
||||
"emptyFocusHistoryBody": "完成一轮专注后,记录会出现在这里。",
|
||||
"emptyNotesHistoryTitle": "还没有复盘",
|
||||
"emptyNotesHistoryBody": "专注后留一句复盘,这里会保留它。",
|
||||
"recordsEmptyTitle": "还没有进展记录",
|
||||
"recordsEmptyBody": "完成一次专注,再留一句记录,这里就会保留今天的进展。",
|
||||
"recordsCalendarTitle": "活动日期",
|
||||
"recordsDateLedgerTitle": "当日账本",
|
||||
"backToToday": "回到今日",
|
||||
"recordsNoIntention": "未命名今日重点",
|
||||
"recordsNoReflection": "还没有进展记录",
|
||||
"recordsMissingProgressHelper": "补一句,之后更容易接上。",
|
||||
"addProgressNote": "补一句进展",
|
||||
"continueThisAction": "继续这件事",
|
||||
"replaceTodayActionTitle": "替换今天的行动?",
|
||||
"replaceTodayActionBody": "会把今天的行动改为“{actionTitle}”,已有记录不会删除。",
|
||||
"@replaceTodayActionBody": {
|
||||
"placeholders": {
|
||||
"actionTitle": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recentFocus": "最近专注",
|
||||
"sessionsTotal": "共 {count} 次",
|
||||
"@sessionsTotal": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"focusMinutes": "专注 {minutes} 分钟",
|
||||
"@focusMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recentReflections": "最近复盘",
|
||||
"recentReflectionsDetail": "值得保留的记录",
|
||||
"focusedMinutes": "已专注 {minutes} 分钟",
|
||||
"@focusedMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"noFocusSessionYet": "还没有完成专注",
|
||||
"habitsCompleted": "{completed}/{total} 完成",
|
||||
"@habitsCompleted": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reflectionCaptured": "今日复盘已记录",
|
||||
"reflectionAction": "用一句话给今天收尾",
|
||||
"nextActionTitle": "下一步",
|
||||
"nextActionSetFocusTitle": "写下今天的重点",
|
||||
"nextActionSetFocusBody": "一个短句就够了,之后还可以调整。",
|
||||
"nextActionStartFocusTitle": "开始 10 分钟",
|
||||
"nextActionStartFocusBody": "写不写都可以,先开始。",
|
||||
"nextActionStartActionBody": "先推进一小段,完成后留一句进展。",
|
||||
"nextActionHabitsTitle": "完成一个小习惯",
|
||||
"nextActionHabitsBody": "趁着还顺手,先做掉一个可重复的小动作。",
|
||||
"nextActionReflectTitle": "写一句记录",
|
||||
"nextActionReflectBody": "记下刚才推进了什么,下次更容易继续。",
|
||||
"nextActionCompleteTitle": "今天已经推进了一点",
|
||||
"nextActionCompleteBody": "进展已经留下,想继续时再来一段。",
|
||||
"nextActionContinueFocus": "再专注 {minutes} 分钟",
|
||||
"@nextActionContinueFocus": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"habitTemplatesTitle": "可以直接开始",
|
||||
"habitTemplatesBody": "添加一个很小但能重复的动作。",
|
||||
"habitTemplateStretch": "2 分钟拉伸",
|
||||
"habitTemplateRead": "读 10 页书",
|
||||
"habitTemplateWalk": "出门走走",
|
||||
"habitTemplateNoScroll": "午餐不刷手机",
|
||||
"languageDetail": "界面语言",
|
||||
"recordsAndPrivacyTitle": "记录与隐私",
|
||||
"recordsAndPrivacyBody": "历史只保存在本机。你可以保留、重置或彻底删除。",
|
||||
"welcomeTrustLabel": "今日",
|
||||
"welcomeTitle": "每天只种一件事。",
|
||||
"welcomeBody": "先选今天最重要的一件。",
|
||||
"welcomeNext": "下一步会从这里开始。",
|
||||
"onboardingFocusBadge": "专注",
|
||||
"onboardingFocusTitle": "先守住 10 分钟。",
|
||||
"onboardingFocusBody": "时间不用长,能开始就有进展。",
|
||||
"onboardingFocusNext": "专注时,把这件事留在眼前。",
|
||||
"onboardingReflectBadge": "记录",
|
||||
"onboardingReflectTitle": "留下一句进展。",
|
||||
"onboardingReflectBody": "结束时记一句,下次就知道从哪里继续。",
|
||||
"onboardingReflectNext": "现在设置今天重点。",
|
||||
"onboardingNext": "继续",
|
||||
"welcomeStart": "设置今天重点",
|
||||
"welcomeSkip": "跳过",
|
||||
"landingTitle": "先开始 10 分钟。",
|
||||
"landingBody": "写不写都可以,先开始。",
|
||||
"landingFocusLabel": "今天想先做什么?",
|
||||
"landingFocusEntry": "写下这次要做的事",
|
||||
"landingFocusHint": "例如:完成上线检查清单",
|
||||
"customFocusDuration": "自定义时长",
|
||||
"customFocusDurationHint": "输入 1-180 分钟",
|
||||
"minutesInputLabel": "分钟数",
|
||||
"landingStart": "开始 10 分钟",
|
||||
"landingSkip": "进入今日页",
|
||||
"defaultHabitPlanTask": "计划一个明确任务",
|
||||
"defaultHabitFocus": "专注 10 分钟",
|
||||
"defaultHabitReflect": "写一句复盘"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,717 @@
|
||||
// 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 navRecords => 'Records';
|
||||
|
||||
@override
|
||||
String get navMe => 'Me';
|
||||
|
||||
@override
|
||||
String get tryAgain => 'Try again';
|
||||
|
||||
@override
|
||||
String get couldNotOpenApp => 'Could not open Focus Seed.';
|
||||
|
||||
@override
|
||||
String get loadingLocalData => 'Loading local data';
|
||||
|
||||
@override
|
||||
String get openFailed => 'Your local data could not be opened.';
|
||||
|
||||
@override
|
||||
String get saveFailed => 'That change could not be saved. Please try again.';
|
||||
|
||||
@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 FOCUS';
|
||||
|
||||
@override
|
||||
String get chooseOneOutcome => 'Name today\'s focus';
|
||||
|
||||
@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 focusCurrentIntention(String intention) {
|
||||
return 'Today\'s focus: $intention';
|
||||
}
|
||||
|
||||
@override
|
||||
String get focusNoIntentionTitle => 'Start this session first';
|
||||
|
||||
@override
|
||||
String get reflectOneMinute => 'Write progress note';
|
||||
|
||||
@override
|
||||
String get startFocus => 'Start focus';
|
||||
|
||||
@override
|
||||
String get startAnotherSession => 'Start another session';
|
||||
|
||||
@override
|
||||
String get setTodaysIntention => 'Set today\'s focus';
|
||||
|
||||
@override
|
||||
String get pause => 'Pause';
|
||||
|
||||
@override
|
||||
String get end => 'End';
|
||||
|
||||
@override
|
||||
String get completeSession => 'Complete session';
|
||||
|
||||
@override
|
||||
String get continueSession => 'Continue session';
|
||||
|
||||
@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 => 'Keep this optional and small.';
|
||||
|
||||
@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/3 daily steps complete';
|
||||
}
|
||||
|
||||
@override
|
||||
String get habitOptions => 'Habit options';
|
||||
|
||||
@override
|
||||
String get rename => 'Rename';
|
||||
|
||||
@override
|
||||
String get delete => 'Delete';
|
||||
|
||||
@override
|
||||
String get habitsTitle => 'Supporting habits';
|
||||
|
||||
@override
|
||||
String get habitsSubtitle => 'Optional repeatable steps. Today\'s focus still comes first.';
|
||||
|
||||
@override
|
||||
String get emptyHabitsTitle => 'Add a supporting 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 textEntryRequired => 'Write a short line first.';
|
||||
|
||||
@override
|
||||
String get setIntentionTitle => 'Set today\'s focus';
|
||||
|
||||
@override
|
||||
String get editIntentionTitle => 'Edit today\'s focus';
|
||||
|
||||
@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 close => 'Close';
|
||||
|
||||
@override
|
||||
String get copy => 'Copy';
|
||||
|
||||
@override
|
||||
String get clearTodayDataTitle => 'Clear today?';
|
||||
|
||||
@override
|
||||
String get clearTodayDataBody => 'This resets today\'s focus, habit checks, focus sessions, and reflection. Past days stay untouched.';
|
||||
|
||||
@override
|
||||
String get clearAllDataTitle => 'Delete all local data?';
|
||||
|
||||
@override
|
||||
String get clearAllDataBody => 'This deletes intentions, habits, focus sessions, and reflections on this device. It cannot be undone.';
|
||||
|
||||
@override
|
||||
String get clear => 'Clear';
|
||||
|
||||
@override
|
||||
String get deleteAll => 'Delete all';
|
||||
|
||||
@override
|
||||
String get privacyTitle => 'Privacy & data';
|
||||
|
||||
@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 clear today\'s progress or delete all local data from settings at any time.';
|
||||
|
||||
@override
|
||||
String get reflectionEmptyError => 'Write one honest sentence.';
|
||||
|
||||
@override
|
||||
String get reflectionSaveError => 'Could not save. Please try again.';
|
||||
|
||||
@override
|
||||
String get reflectionSheetTitle => 'Leave one progress note';
|
||||
|
||||
@override
|
||||
String get reflectionSheetSubtitle => 'Save what moved forward so next time has a clear start.';
|
||||
|
||||
@override
|
||||
String get reflectionHint => 'What moved forward?';
|
||||
|
||||
@override
|
||||
String get reflectionMoodLabel => 'Mood is optional';
|
||||
|
||||
@override
|
||||
String get savingLabel => 'Saving';
|
||||
|
||||
@override
|
||||
String get saveReflection => 'Save note';
|
||||
|
||||
@override
|
||||
String get moodCalm => 'Calm';
|
||||
|
||||
@override
|
||||
String get moodClear => 'Clear';
|
||||
|
||||
@override
|
||||
String get moodProud => 'Proud';
|
||||
|
||||
@override
|
||||
String get moodTired => 'Tired';
|
||||
|
||||
@override
|
||||
String get meTitle => 'Me';
|
||||
|
||||
@override
|
||||
String get meSubtitle => 'Manage your rhythm, appearance, and records.';
|
||||
|
||||
@override
|
||||
String get appSettingsTitle => 'App settings';
|
||||
|
||||
@override
|
||||
String get appearance => 'Theme';
|
||||
|
||||
@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 preferencesTitle => 'Preferences';
|
||||
|
||||
@override
|
||||
String get preferencesDetail => 'Theme, language, and default focus';
|
||||
|
||||
@override
|
||||
String get defaultFocusDuration => 'Default focus';
|
||||
|
||||
@override
|
||||
String get defaultFocusDurationDetail => 'Next session';
|
||||
|
||||
@override
|
||||
String get localData => 'Local records';
|
||||
|
||||
@override
|
||||
String get localDataDetail => 'Private on device';
|
||||
|
||||
@override
|
||||
String get personalGardenTitle => 'My accumulation';
|
||||
|
||||
@override
|
||||
String get personalGardenSubtitle => 'Your local action records, progress notes, and app controls stay on this device.';
|
||||
|
||||
@override
|
||||
String get focusDataLabel => 'Focus';
|
||||
|
||||
@override
|
||||
String get habitsDataLabel => 'Habits';
|
||||
|
||||
@override
|
||||
String get notesDataLabel => 'Progress';
|
||||
|
||||
@override
|
||||
String get totalActionTimeLabel => 'Action time';
|
||||
|
||||
@override
|
||||
String get recordsCountLabel => 'Records';
|
||||
|
||||
@override
|
||||
String get progressNotesCountLabel => 'Progress notes';
|
||||
|
||||
@override
|
||||
String get clearToday => 'Clear today';
|
||||
|
||||
@override
|
||||
String get clearTodaySubtitle => 'Reset only today\'s loop.';
|
||||
|
||||
@override
|
||||
String get privacySubtitle => 'No account or cloud sync.';
|
||||
|
||||
@override
|
||||
String get deleteAllLocalData => 'Delete all data';
|
||||
|
||||
@override
|
||||
String get deleteAllLocalDataSubtitle => 'Remove this device\'s Focus Seed records.';
|
||||
|
||||
@override
|
||||
String get todayDataCleared => 'Today\'s data cleared';
|
||||
|
||||
@override
|
||||
String get localDataCleared => 'Local data deleted';
|
||||
|
||||
@override
|
||||
String get todayLoopLabel => 'TODAY\'S LOOP';
|
||||
|
||||
@override
|
||||
String get todayLoopStartTitle => 'Start with one clear focus';
|
||||
|
||||
@override
|
||||
String get todayLoopStartBody => 'Pick the one result that matters today.';
|
||||
|
||||
@override
|
||||
String get todayLoopActiveTitle => 'Your day is taking shape';
|
||||
|
||||
@override
|
||||
String get todayLoopActiveBody => 'Focus once, then leave one useful note.';
|
||||
|
||||
@override
|
||||
String get todayLoopDoneTitle => 'Progress saved';
|
||||
|
||||
@override
|
||||
String get todayLoopDoneBody => 'You moved today forward and left a clear place to continue.';
|
||||
|
||||
@override
|
||||
String todayLoopProgress(int completed) {
|
||||
return '$completed/3 done';
|
||||
}
|
||||
|
||||
@override
|
||||
String get reviewNow => 'Write a note';
|
||||
|
||||
@override
|
||||
String get weekFocusLabel => 'Focus';
|
||||
|
||||
@override
|
||||
String get streakLabel => 'Streak';
|
||||
|
||||
@override
|
||||
String get activeDaysLabel => 'Active';
|
||||
|
||||
@override
|
||||
String get habitRateLabel => 'Habits';
|
||||
|
||||
@override
|
||||
String daysValue(int count) {
|
||||
return '${count}d';
|
||||
}
|
||||
|
||||
@override
|
||||
String activeDaysValue(int count) {
|
||||
return '$count/7';
|
||||
}
|
||||
|
||||
@override
|
||||
String get viewHistory => 'Record library';
|
||||
|
||||
@override
|
||||
String get viewHistorySubtitle => 'Open focus sessions and reflections.';
|
||||
|
||||
@override
|
||||
String get historyTitle => 'History';
|
||||
|
||||
@override
|
||||
String get focusHistory => 'Focus';
|
||||
|
||||
@override
|
||||
String get notesHistory => 'Notes';
|
||||
|
||||
@override
|
||||
String get emptyFocusHistoryTitle => 'No focus sessions yet';
|
||||
|
||||
@override
|
||||
String get emptyFocusHistoryBody => 'Finish one focus session and it will appear here.';
|
||||
|
||||
@override
|
||||
String get emptyNotesHistoryTitle => 'No notes yet';
|
||||
|
||||
@override
|
||||
String get emptyNotesHistoryBody => 'Save one reflection after a session and it will appear here.';
|
||||
|
||||
@override
|
||||
String get recordsEmptyTitle => 'No progress records yet';
|
||||
|
||||
@override
|
||||
String get recordsEmptyBody => 'Finish one focus session and leave a note to keep today\'s progress here.';
|
||||
|
||||
@override
|
||||
String get recordsCalendarTitle => 'Activity dates';
|
||||
|
||||
@override
|
||||
String get recordsDateLedgerTitle => 'Date ledger';
|
||||
|
||||
@override
|
||||
String get backToToday => 'Back to Today';
|
||||
|
||||
@override
|
||||
String get recordsNoIntention => 'Unnamed daily focus';
|
||||
|
||||
@override
|
||||
String get recordsNoReflection => 'No progress note yet';
|
||||
|
||||
@override
|
||||
String get recordsMissingProgressHelper => 'Add one line so this is easier to pick up later.';
|
||||
|
||||
@override
|
||||
String get addProgressNote => 'Add progress note';
|
||||
|
||||
@override
|
||||
String get continueThisAction => 'Continue this';
|
||||
|
||||
@override
|
||||
String get replaceTodayActionTitle => 'Replace today\'s action?';
|
||||
|
||||
@override
|
||||
String replaceTodayActionBody(String actionTitle) {
|
||||
return 'This will change today\'s action to \"$actionTitle\". Existing records will stay.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recentFocus => 'Recent focus';
|
||||
|
||||
@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 => 'Write one sentence to close today';
|
||||
|
||||
@override
|
||||
String get nextActionTitle => 'Next action';
|
||||
|
||||
@override
|
||||
String get nextActionSetFocusTitle => 'Write today\'s focus';
|
||||
|
||||
@override
|
||||
String get nextActionSetFocusBody => 'One short line is enough. You can adjust it later.';
|
||||
|
||||
@override
|
||||
String get nextActionStartFocusTitle => 'Start 10 minutes';
|
||||
|
||||
@override
|
||||
String get nextActionStartFocusBody => 'Writing the action is optional. Start first.';
|
||||
|
||||
@override
|
||||
String get nextActionStartActionBody => 'Move this forward in one small session, then leave one progress note.';
|
||||
|
||||
@override
|
||||
String get nextActionHabitsTitle => 'Finish one small habit';
|
||||
|
||||
@override
|
||||
String get nextActionHabitsBody => 'Complete one repeatable step while it is still easy.';
|
||||
|
||||
@override
|
||||
String get nextActionReflectTitle => 'Write a quick note';
|
||||
|
||||
@override
|
||||
String get nextActionReflectBody => 'Record what moved forward so the next start is easier.';
|
||||
|
||||
@override
|
||||
String get nextActionCompleteTitle => 'Today moved forward';
|
||||
|
||||
@override
|
||||
String get nextActionCompleteBody => 'Your note is saved. Continue only if it still feels useful.';
|
||||
|
||||
@override
|
||||
String nextActionContinueFocus(int minutes) {
|
||||
return 'Focus $minutes min again';
|
||||
}
|
||||
|
||||
@override
|
||||
String get habitTemplatesTitle => 'Useful starts';
|
||||
|
||||
@override
|
||||
String get habitTemplatesBody => 'Add one tiny repeatable action.';
|
||||
|
||||
@override
|
||||
String get habitTemplateStretch => '2 min stretch';
|
||||
|
||||
@override
|
||||
String get habitTemplateRead => 'Read 10 pages';
|
||||
|
||||
@override
|
||||
String get habitTemplateWalk => 'Walk outside';
|
||||
|
||||
@override
|
||||
String get habitTemplateNoScroll => 'No-scroll lunch';
|
||||
|
||||
@override
|
||||
String get languageDetail => 'Interface language';
|
||||
|
||||
@override
|
||||
String get recordsAndPrivacyTitle => 'Records & privacy';
|
||||
|
||||
@override
|
||||
String get recordsAndPrivacyBody => 'Your history stays local. Keep, reset, or delete it when needed.';
|
||||
|
||||
@override
|
||||
String get welcomeTrustLabel => 'TODAY';
|
||||
|
||||
@override
|
||||
String get welcomeTitle => 'Plant just one thing each day.';
|
||||
|
||||
@override
|
||||
String get welcomeBody => 'Start with today\'s most important thing.';
|
||||
|
||||
@override
|
||||
String get welcomeNext => 'The next step starts here.';
|
||||
|
||||
@override
|
||||
String get onboardingFocusBadge => 'FOCUS';
|
||||
|
||||
@override
|
||||
String get onboardingFocusTitle => 'Protect 10 minutes first.';
|
||||
|
||||
@override
|
||||
String get onboardingFocusBody => 'It does not have to be long. Starting creates progress.';
|
||||
|
||||
@override
|
||||
String get onboardingFocusNext => 'Keep this one thing in front of you while you focus.';
|
||||
|
||||
@override
|
||||
String get onboardingReflectBadge => 'NOTE';
|
||||
|
||||
@override
|
||||
String get onboardingReflectTitle => 'Leave one progress note.';
|
||||
|
||||
@override
|
||||
String get onboardingReflectBody => 'When you finish, write one line so you know where to continue next time.';
|
||||
|
||||
@override
|
||||
String get onboardingReflectNext => 'Now set today\'s focus.';
|
||||
|
||||
@override
|
||||
String get onboardingNext => 'Continue';
|
||||
|
||||
@override
|
||||
String get welcomeStart => 'Set today\'s focus';
|
||||
|
||||
@override
|
||||
String get welcomeSkip => 'Skip';
|
||||
|
||||
@override
|
||||
String get landingTitle => 'Start with 10 minutes.';
|
||||
|
||||
@override
|
||||
String get landingBody => 'Writing is optional. Start first.';
|
||||
|
||||
@override
|
||||
String get landingFocusLabel => 'What do you want to do first?';
|
||||
|
||||
@override
|
||||
String get landingFocusEntry => 'Write down what you will do';
|
||||
|
||||
@override
|
||||
String get landingFocusHint => 'Example: finish the launch checklist';
|
||||
|
||||
@override
|
||||
String get customFocusDuration => 'Custom length';
|
||||
|
||||
@override
|
||||
String get customFocusDurationHint => 'Enter 1-180 minutes';
|
||||
|
||||
@override
|
||||
String get minutesInputLabel => 'Minutes';
|
||||
|
||||
@override
|
||||
String get landingStart => 'Start 10 minutes';
|
||||
|
||||
@override
|
||||
String get landingSkip => 'Enter Today';
|
||||
|
||||
@override
|
||||
String get defaultHabitPlanTask => 'Plan one clear task';
|
||||
|
||||
@override
|
||||
String get defaultHabitFocus => 'Focus for 10 minutes';
|
||||
|
||||
@override
|
||||
String get defaultHabitReflect => 'Write one-line reflection';
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
// 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 navRecords => '记录';
|
||||
|
||||
@override
|
||||
String get navMe => '我的';
|
||||
|
||||
@override
|
||||
String get tryAgain => '重试';
|
||||
|
||||
@override
|
||||
String get couldNotOpenApp => '无法打开 Focus Seed。';
|
||||
|
||||
@override
|
||||
String get loadingLocalData => '正在读取本地数据';
|
||||
|
||||
@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 focusCurrentIntention(String intention) {
|
||||
return '今日重点:$intention';
|
||||
}
|
||||
|
||||
@override
|
||||
String get focusNoIntentionTitle => '先开始这一段';
|
||||
|
||||
@override
|
||||
String get reflectOneMinute => '写一句进展';
|
||||
|
||||
@override
|
||||
String get startFocus => '开始专注';
|
||||
|
||||
@override
|
||||
String get startAnotherSession => '再来一轮';
|
||||
|
||||
@override
|
||||
String get setTodaysIntention => '设置今日重点';
|
||||
|
||||
@override
|
||||
String get pause => '暂停';
|
||||
|
||||
@override
|
||||
String get end => '结束';
|
||||
|
||||
@override
|
||||
String get completeSession => '完成本轮';
|
||||
|
||||
@override
|
||||
String get continueSession => '继续本轮';
|
||||
|
||||
@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/3';
|
||||
}
|
||||
|
||||
@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 textEntryRequired => '先写一个短句。';
|
||||
|
||||
@override
|
||||
String get setIntentionTitle => '设置今日重点';
|
||||
|
||||
@override
|
||||
String get editIntentionTitle => '编辑今日重点';
|
||||
|
||||
@override
|
||||
String get intentionHint => '今天最重要的是什么?';
|
||||
|
||||
@override
|
||||
String get deleteHabitTitle => '删除习惯?';
|
||||
|
||||
@override
|
||||
String get deleteHabitBody => '这个习惯的历史完成记录也会删除。';
|
||||
|
||||
@override
|
||||
String get close => '关闭';
|
||||
|
||||
@override
|
||||
String get copy => '复制';
|
||||
|
||||
@override
|
||||
String get clearTodayDataTitle => '清理今日数据?';
|
||||
|
||||
@override
|
||||
String get clearTodayDataBody => '只重置今天的重点、打卡、专注记录和复盘,过去日期的数据会保留。';
|
||||
|
||||
@override
|
||||
String get clearAllDataTitle => '删除全部本地数据?';
|
||||
|
||||
@override
|
||||
String get clearAllDataBody => '会删除本机的目标、习惯、专注记录和复盘,无法撤销。';
|
||||
|
||||
@override
|
||||
String get clear => '清除';
|
||||
|
||||
@override
|
||||
String get deleteAll => '全部删除';
|
||||
|
||||
@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 reflectionMoodLabel => '状态可以稍后补';
|
||||
|
||||
@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 appSettingsTitle => '应用设置';
|
||||
|
||||
@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 preferencesTitle => '偏好';
|
||||
|
||||
@override
|
||||
String get preferencesDetail => '主题、语言和默认专注';
|
||||
|
||||
@override
|
||||
String get defaultFocusDuration => '默认专注';
|
||||
|
||||
@override
|
||||
String get defaultFocusDurationDetail => '下一轮';
|
||||
|
||||
@override
|
||||
String get localData => '本地记录';
|
||||
|
||||
@override
|
||||
String get localDataDetail => '只在本机';
|
||||
|
||||
@override
|
||||
String get personalGardenTitle => '我的累计';
|
||||
|
||||
@override
|
||||
String get personalGardenSubtitle => '你的本地行动记录、进展和应用控制都保留在这台设备上。';
|
||||
|
||||
@override
|
||||
String get focusDataLabel => '专注';
|
||||
|
||||
@override
|
||||
String get habitsDataLabel => '习惯';
|
||||
|
||||
@override
|
||||
String get notesDataLabel => '进展';
|
||||
|
||||
@override
|
||||
String get totalActionTimeLabel => '行动时间';
|
||||
|
||||
@override
|
||||
String get recordsCountLabel => '记录';
|
||||
|
||||
@override
|
||||
String get progressNotesCountLabel => '进展';
|
||||
|
||||
@override
|
||||
String get clearToday => '清理今天';
|
||||
|
||||
@override
|
||||
String get clearTodaySubtitle => '只重置今天的闭环。';
|
||||
|
||||
@override
|
||||
String get privacySubtitle => '无账号,无云同步。';
|
||||
|
||||
@override
|
||||
String get deleteAllLocalData => '删除全部数据';
|
||||
|
||||
@override
|
||||
String get deleteAllLocalDataSubtitle => '移除这台设备上的 Focus Seed 记录。';
|
||||
|
||||
@override
|
||||
String get todayDataCleared => '今日数据已清理';
|
||||
|
||||
@override
|
||||
String get localDataCleared => '本地数据已删除';
|
||||
|
||||
@override
|
||||
String get todayLoopLabel => '今日闭环';
|
||||
|
||||
@override
|
||||
String get todayLoopStartTitle => '先定下一个重点';
|
||||
|
||||
@override
|
||||
String get todayLoopStartBody => '先写下今天最重要的一件事。';
|
||||
|
||||
@override
|
||||
String get todayLoopActiveTitle => '今天正在成形';
|
||||
|
||||
@override
|
||||
String get todayLoopActiveBody => '先完成一轮专注,再留一句有用的记录。';
|
||||
|
||||
@override
|
||||
String get todayLoopDoneTitle => '进展已保存';
|
||||
|
||||
@override
|
||||
String get todayLoopDoneBody => '今天已经推进了一点,下次也知道从哪里继续。';
|
||||
|
||||
@override
|
||||
String todayLoopProgress(int completed) {
|
||||
return '已完成 $completed/3';
|
||||
}
|
||||
|
||||
@override
|
||||
String get reviewNow => '写一句记录';
|
||||
|
||||
@override
|
||||
String get weekFocusLabel => '专注';
|
||||
|
||||
@override
|
||||
String get streakLabel => '连续';
|
||||
|
||||
@override
|
||||
String get activeDaysLabel => '活跃';
|
||||
|
||||
@override
|
||||
String get habitRateLabel => '习惯';
|
||||
|
||||
@override
|
||||
String daysValue(int count) {
|
||||
return '$count天';
|
||||
}
|
||||
|
||||
@override
|
||||
String activeDaysValue(int count) {
|
||||
return '$count/7';
|
||||
}
|
||||
|
||||
@override
|
||||
String get viewHistory => '记录库';
|
||||
|
||||
@override
|
||||
String get viewHistorySubtitle => '查看专注记录和复盘。';
|
||||
|
||||
@override
|
||||
String get historyTitle => '历史';
|
||||
|
||||
@override
|
||||
String get focusHistory => '专注';
|
||||
|
||||
@override
|
||||
String get notesHistory => '复盘';
|
||||
|
||||
@override
|
||||
String get emptyFocusHistoryTitle => '还没有专注记录';
|
||||
|
||||
@override
|
||||
String get emptyFocusHistoryBody => '完成一轮专注后,记录会出现在这里。';
|
||||
|
||||
@override
|
||||
String get emptyNotesHistoryTitle => '还没有复盘';
|
||||
|
||||
@override
|
||||
String get emptyNotesHistoryBody => '专注后留一句复盘,这里会保留它。';
|
||||
|
||||
@override
|
||||
String get recordsEmptyTitle => '还没有进展记录';
|
||||
|
||||
@override
|
||||
String get recordsEmptyBody => '完成一次专注,再留一句记录,这里就会保留今天的进展。';
|
||||
|
||||
@override
|
||||
String get recordsCalendarTitle => '活动日期';
|
||||
|
||||
@override
|
||||
String get recordsDateLedgerTitle => '当日账本';
|
||||
|
||||
@override
|
||||
String get backToToday => '回到今日';
|
||||
|
||||
@override
|
||||
String get recordsNoIntention => '未命名今日重点';
|
||||
|
||||
@override
|
||||
String get recordsNoReflection => '还没有进展记录';
|
||||
|
||||
@override
|
||||
String get recordsMissingProgressHelper => '补一句,之后更容易接上。';
|
||||
|
||||
@override
|
||||
String get addProgressNote => '补一句进展';
|
||||
|
||||
@override
|
||||
String get continueThisAction => '继续这件事';
|
||||
|
||||
@override
|
||||
String get replaceTodayActionTitle => '替换今天的行动?';
|
||||
|
||||
@override
|
||||
String replaceTodayActionBody(String actionTitle) {
|
||||
return '会把今天的行动改为“$actionTitle”,已有记录不会删除。';
|
||||
}
|
||||
|
||||
@override
|
||||
String get recentFocus => '最近专注';
|
||||
|
||||
@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 nextActionTitle => '下一步';
|
||||
|
||||
@override
|
||||
String get nextActionSetFocusTitle => '写下今天的重点';
|
||||
|
||||
@override
|
||||
String get nextActionSetFocusBody => '一个短句就够了,之后还可以调整。';
|
||||
|
||||
@override
|
||||
String get nextActionStartFocusTitle => '开始 10 分钟';
|
||||
|
||||
@override
|
||||
String get nextActionStartFocusBody => '写不写都可以,先开始。';
|
||||
|
||||
@override
|
||||
String get nextActionStartActionBody => '先推进一小段,完成后留一句进展。';
|
||||
|
||||
@override
|
||||
String get nextActionHabitsTitle => '完成一个小习惯';
|
||||
|
||||
@override
|
||||
String get nextActionHabitsBody => '趁着还顺手,先做掉一个可重复的小动作。';
|
||||
|
||||
@override
|
||||
String get nextActionReflectTitle => '写一句记录';
|
||||
|
||||
@override
|
||||
String get nextActionReflectBody => '记下刚才推进了什么,下次更容易继续。';
|
||||
|
||||
@override
|
||||
String get nextActionCompleteTitle => '今天已经推进了一点';
|
||||
|
||||
@override
|
||||
String get nextActionCompleteBody => '进展已经留下,想继续时再来一段。';
|
||||
|
||||
@override
|
||||
String nextActionContinueFocus(int minutes) {
|
||||
return '再专注 $minutes 分钟';
|
||||
}
|
||||
|
||||
@override
|
||||
String get habitTemplatesTitle => '可以直接开始';
|
||||
|
||||
@override
|
||||
String get habitTemplatesBody => '添加一个很小但能重复的动作。';
|
||||
|
||||
@override
|
||||
String get habitTemplateStretch => '2 分钟拉伸';
|
||||
|
||||
@override
|
||||
String get habitTemplateRead => '读 10 页书';
|
||||
|
||||
@override
|
||||
String get habitTemplateWalk => '出门走走';
|
||||
|
||||
@override
|
||||
String get habitTemplateNoScroll => '午餐不刷手机';
|
||||
|
||||
@override
|
||||
String get languageDetail => '界面语言';
|
||||
|
||||
@override
|
||||
String get recordsAndPrivacyTitle => '记录与隐私';
|
||||
|
||||
@override
|
||||
String get recordsAndPrivacyBody => '历史只保存在本机。你可以保留、重置或彻底删除。';
|
||||
|
||||
@override
|
||||
String get welcomeTrustLabel => '今日';
|
||||
|
||||
@override
|
||||
String get welcomeTitle => '每天只种一件事。';
|
||||
|
||||
@override
|
||||
String get welcomeBody => '先选今天最重要的一件。';
|
||||
|
||||
@override
|
||||
String get welcomeNext => '下一步会从这里开始。';
|
||||
|
||||
@override
|
||||
String get onboardingFocusBadge => '专注';
|
||||
|
||||
@override
|
||||
String get onboardingFocusTitle => '先守住 10 分钟。';
|
||||
|
||||
@override
|
||||
String get onboardingFocusBody => '时间不用长,能开始就有进展。';
|
||||
|
||||
@override
|
||||
String get onboardingFocusNext => '专注时,把这件事留在眼前。';
|
||||
|
||||
@override
|
||||
String get onboardingReflectBadge => '记录';
|
||||
|
||||
@override
|
||||
String get onboardingReflectTitle => '留下一句进展。';
|
||||
|
||||
@override
|
||||
String get onboardingReflectBody => '结束时记一句,下次就知道从哪里继续。';
|
||||
|
||||
@override
|
||||
String get onboardingReflectNext => '现在设置今天重点。';
|
||||
|
||||
@override
|
||||
String get onboardingNext => '继续';
|
||||
|
||||
@override
|
||||
String get welcomeStart => '设置今天重点';
|
||||
|
||||
@override
|
||||
String get welcomeSkip => '跳过';
|
||||
|
||||
@override
|
||||
String get landingTitle => '先开始 10 分钟。';
|
||||
|
||||
@override
|
||||
String get landingBody => '写不写都可以,先开始。';
|
||||
|
||||
@override
|
||||
String get landingFocusLabel => '今天想先做什么?';
|
||||
|
||||
@override
|
||||
String get landingFocusEntry => '写下这次要做的事';
|
||||
|
||||
@override
|
||||
String get landingFocusHint => '例如:完成上线检查清单';
|
||||
|
||||
@override
|
||||
String get customFocusDuration => '自定义时长';
|
||||
|
||||
@override
|
||||
String get customFocusDurationHint => '输入 1-180 分钟';
|
||||
|
||||
@override
|
||||
String get minutesInputLabel => '分钟数';
|
||||
|
||||
@override
|
||||
String get landingStart => '开始 10 分钟';
|
||||
|
||||
@override
|
||||
String get landingSkip => '进入今日页';
|
||||
|
||||
@override
|
||||
String get defaultHabitPlanTask => '计划一个明确任务';
|
||||
|
||||
@override
|
||||
String get defaultHabitFocus => '专注 10 分钟';
|
||||
|
||||
@override
|
||||
String get defaultHabitReflect => '写一句复盘';
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../core/ambience.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,
|
||||
};
|
||||
}
|
||||
|
||||
List<String> localizedStarterHabits(AppLocalizations l10n) {
|
||||
return [
|
||||
l10n.defaultHabitPlanTask,
|
||||
l10n.defaultHabitFocus,
|
||||
l10n.defaultHabitReflect,
|
||||
];
|
||||
}
|
||||
@@ -1,50 +1,166 @@
|
||||
import '../core/focus_durations.dart';
|
||||
import 'daily_action.dart';
|
||||
import 'focus_session.dart';
|
||||
import 'habit.dart';
|
||||
import 'progress_note.dart';
|
||||
import 'reflection_entry.dart';
|
||||
|
||||
class AppState {
|
||||
const AppState({
|
||||
required this.anchor,
|
||||
required this.anchorDateKey,
|
||||
required this.habits,
|
||||
required this.sessions,
|
||||
required this.reflections,
|
||||
this.actions = const [],
|
||||
this.selectedActionId = '',
|
||||
this.progressNotes = const [],
|
||||
this.recentLabels = const [],
|
||||
this.defaultFocusMinutes = defaultFocusDurationMinutes,
|
||||
});
|
||||
|
||||
final String anchor;
|
||||
final String anchorDateKey;
|
||||
final List<Habit> habits;
|
||||
final List<FocusSession> sessions;
|
||||
final List<ReflectionEntry> reflections;
|
||||
final List<DailyAction> actions;
|
||||
final String selectedActionId;
|
||||
final List<ProgressNote> progressNotes;
|
||||
final List<String> recentLabels;
|
||||
final int defaultFocusMinutes;
|
||||
|
||||
bool get isFreshStart => habits.isEmpty && sessions.isEmpty && anchor.isEmpty;
|
||||
bool get isFreshStart =>
|
||||
habits.isEmpty &&
|
||||
sessions.isEmpty &&
|
||||
reflections.isEmpty &&
|
||||
anchor.isEmpty &&
|
||||
actions.isEmpty &&
|
||||
progressNotes.isEmpty;
|
||||
|
||||
String activeAnchor(String dateKey) {
|
||||
return anchorDateKey == dateKey ? anchor : '';
|
||||
}
|
||||
|
||||
List<DailyAction> actionsForDate(
|
||||
String dateKey, {
|
||||
bool includeArchived = false,
|
||||
}) {
|
||||
final filtered = actions
|
||||
.where(
|
||||
(action) =>
|
||||
action.dateKey == dateKey &&
|
||||
(includeArchived || !action.isArchived),
|
||||
)
|
||||
.toList(growable: false);
|
||||
return [...filtered]..sort((a, b) {
|
||||
final byOrder = a.orderIndex.compareTo(b.orderIndex);
|
||||
if (byOrder != 0) {
|
||||
return byOrder;
|
||||
}
|
||||
return a.createdAt.compareTo(b.createdAt);
|
||||
});
|
||||
}
|
||||
|
||||
DailyAction? actionById(String? id) {
|
||||
if (id == null || id.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
for (final action in actions) {
|
||||
if (action.id == id) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DailyAction? selectedAction(String dateKey) {
|
||||
final todayActions = actionsForDate(dateKey);
|
||||
if (todayActions.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final selected = actionById(selectedActionId);
|
||||
if (selected != null &&
|
||||
selected.dateKey == dateKey &&
|
||||
!selected.isArchived) {
|
||||
return selected;
|
||||
}
|
||||
for (final action in todayActions) {
|
||||
if (action.status == DailyActionStatus.active ||
|
||||
action.status == DailyActionStatus.focused) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
return todayActions.first;
|
||||
}
|
||||
|
||||
List<FocusSession> sessionsForAction(String actionId) {
|
||||
return sessions
|
||||
.where((session) => session.actionId == actionId)
|
||||
.toList(growable: false)
|
||||
..sort((a, b) => b.completedAt.compareTo(a.completedAt));
|
||||
}
|
||||
|
||||
List<ProgressNote> progressNotesForAction(String actionId) {
|
||||
return progressNotes
|
||||
.where((note) => note.actionId == actionId)
|
||||
.toList(growable: false)
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
}
|
||||
|
||||
AppState copyWith({
|
||||
String? anchor,
|
||||
String? anchorDateKey,
|
||||
List<Habit>? habits,
|
||||
List<FocusSession>? sessions,
|
||||
List<ReflectionEntry>? reflections,
|
||||
List<DailyAction>? actions,
|
||||
String? selectedActionId,
|
||||
List<ProgressNote>? progressNotes,
|
||||
List<String>? recentLabels,
|
||||
int? defaultFocusMinutes,
|
||||
}) {
|
||||
return AppState(
|
||||
anchor: anchor ?? this.anchor,
|
||||
anchorDateKey: anchorDateKey ?? this.anchorDateKey,
|
||||
habits: habits ?? this.habits,
|
||||
sessions: sessions ?? this.sessions,
|
||||
reflections: reflections ?? this.reflections,
|
||||
actions: actions ?? this.actions,
|
||||
selectedActionId: selectedActionId ?? this.selectedActionId,
|
||||
progressNotes: progressNotes ?? this.progressNotes,
|
||||
recentLabels: recentLabels ?? this.recentLabels,
|
||||
defaultFocusMinutes: defaultFocusMinutes ?? this.defaultFocusMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
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':
|
||||
reflections.map((reflection) => reflection.toJson()).toList(),
|
||||
'actions': actions.map((action) => action.toJson()).toList(),
|
||||
'selectedActionId': selectedActionId,
|
||||
'progressNotes': progressNotes.map((note) => note.toJson()).toList(),
|
||||
'recentLabels': recentLabels,
|
||||
'defaultFocusMinutes': defaultFocusMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
static AppState fromJson(Map<String, dynamic> json) {
|
||||
final anchor = (json['anchor'] as String?) ?? '';
|
||||
final defaultMinutes = _validDefaultFocusMinutes(
|
||||
json['defaultFocusMinutes'] as int?,
|
||||
);
|
||||
return AppState(
|
||||
anchor: (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(),
|
||||
@@ -52,6 +168,14 @@ class AppState {
|
||||
_jsonObjects(
|
||||
json['reflections'],
|
||||
).map(ReflectionEntry.fromJson).toList(),
|
||||
actions: _jsonObjects(json['actions']).map(DailyAction.fromJson).toList(),
|
||||
selectedActionId: (json['selectedActionId'] as String?) ?? '',
|
||||
progressNotes:
|
||||
_jsonObjects(
|
||||
json['progressNotes'],
|
||||
).map(ProgressNote.fromJson).toList(),
|
||||
recentLabels: _stringList(json['recentLabels']),
|
||||
defaultFocusMinutes: defaultMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,26 +186,45 @@ class AppState {
|
||||
return value.whereType<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
static AppState starter() {
|
||||
static List<String> _stringList(Object? value) {
|
||||
if (value is! List<Object?>) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final item in value)
|
||||
if (item is String && item.trim().isNotEmpty) item.trim(),
|
||||
];
|
||||
}
|
||||
|
||||
static AppState starter({List<String>? defaultHabitTitles}) {
|
||||
final now = DateTime.now();
|
||||
final titles =
|
||||
defaultHabitTitles?.length == 3
|
||||
? defaultHabitTitles!
|
||||
: const [
|
||||
'Plan one clear task',
|
||||
'Focus for 10 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 +233,18 @@ class AppState {
|
||||
reflections: const [],
|
||||
);
|
||||
}
|
||||
|
||||
static int _validDefaultFocusMinutes(int? value) {
|
||||
if (value == null || !isFocusDurationPreset(value)) {
|
||||
return defaultFocusDurationMinutes;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String _currentDateKey([DateTime? value]) {
|
||||
final date = value ?? DateTime.now();
|
||||
return '${date.year.toString().padLeft(4, '0')}-'
|
||||
'${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,
|
||||
);
|
||||
}
|
||||
@@ -3,25 +3,49 @@ class FocusSession {
|
||||
required this.id,
|
||||
required this.minutes,
|
||||
required this.completedAt,
|
||||
});
|
||||
this.intention = '',
|
||||
this.actionId,
|
||||
String? actionTitleSnapshot,
|
||||
int? actualSeconds,
|
||||
}) : actualSeconds = actualSeconds ?? minutes * 60,
|
||||
actionTitleSnapshot = actionTitleSnapshot ?? intention;
|
||||
|
||||
final String id;
|
||||
final int minutes;
|
||||
final int actualSeconds;
|
||||
final DateTime completedAt;
|
||||
final String intention;
|
||||
final String? actionId;
|
||||
final String actionTitleSnapshot;
|
||||
|
||||
int get actualMinutes => actualSeconds <= 0 ? 0 : (actualSeconds + 59) ~/ 60;
|
||||
String get actionTitle =>
|
||||
actionTitleSnapshot.trim().isEmpty
|
||||
? intention.trim()
|
||||
: actionTitleSnapshot.trim();
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'minutes': minutes,
|
||||
'actualSeconds': actualSeconds,
|
||||
'completedAt': completedAt.toIso8601String(),
|
||||
'intention': intention,
|
||||
'actionId': actionId,
|
||||
'actionTitleSnapshot': actionTitleSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
static FocusSession fromJson(Map<String, dynamic> json) {
|
||||
final minutes = json['minutes'] as int;
|
||||
return FocusSession(
|
||||
id: json['id'] as String,
|
||||
minutes: json['minutes'] as int,
|
||||
minutes: minutes,
|
||||
actualSeconds: (json['actualSeconds'] as int?) ?? minutes * 60,
|
||||
completedAt: DateTime.parse(json['completedAt'] as String),
|
||||
intention: (json['intention'] as String?) ?? '',
|
||||
actionId: json['actionId'] as String?,
|
||||
actionTitleSnapshot: json['actionTitleSnapshot'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
class TodayProgress {
|
||||
const TodayProgress({
|
||||
required this.dateKey,
|
||||
required this.intention,
|
||||
required this.focusMinutes,
|
||||
required this.completedHabits,
|
||||
required this.totalHabits,
|
||||
required this.hasReflection,
|
||||
});
|
||||
|
||||
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) ||
|
||||
state.progressNotes.any(
|
||||
(entry) => entry.dateKey == dateKey && entry.hasContent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final String dateKey;
|
||||
final String intention;
|
||||
final int focusMinutes;
|
||||
final int completedHabits;
|
||||
final int totalHabits;
|
||||
final bool hasReflection;
|
||||
|
||||
bool get hasIntention => intention.isNotEmpty;
|
||||
bool get hasFocus => focusMinutes > 0;
|
||||
bool get hasHabits => completedHabits > 0;
|
||||
bool get isLoopClosed => hasIntention && hasFocus && hasReflection;
|
||||
int get completedSteps =>
|
||||
[
|
||||
hasIntention,
|
||||
hasFocus,
|
||||
hasReflection,
|
||||
].where((complete) => complete).length;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
enum PremiumState { free, premium }
|
||||
|
||||
enum AdPlacement { statsBottom, reflectionComplete }
|
||||
|
||||
sealed class PurchaseResult {
|
||||
const PurchaseResult();
|
||||
|
||||
static const unavailable = PurchaseUnavailable();
|
||||
}
|
||||
|
||||
class PurchaseUnavailable extends PurchaseResult {
|
||||
const PurchaseUnavailable();
|
||||
}
|
||||
|
||||
abstract interface class MonetizationProvider {
|
||||
bool get adsEnabled;
|
||||
bool get billingEnabled;
|
||||
Stream<PremiumState> premiumState();
|
||||
Future<void> showInterstitialIfEligible(AdPlacement placement);
|
||||
Future<PurchaseResult> startPurchase(String productId);
|
||||
Future<void> restorePurchases();
|
||||
}
|
||||
|
||||
class NoopMonetizationProvider implements MonetizationProvider {
|
||||
const NoopMonetizationProvider();
|
||||
|
||||
@override
|
||||
bool get adsEnabled => false;
|
||||
|
||||
@override
|
||||
bool get billingEnabled => false;
|
||||
|
||||
@override
|
||||
Stream<PremiumState> premiumState() => Stream.value(PremiumState.free);
|
||||
|
||||
@override
|
||||
Future<void> showInterstitialIfEligible(AdPlacement placement) async {}
|
||||
|
||||
@override
|
||||
Future<PurchaseResult> startPurchase(String productId) async {
|
||||
return PurchaseResult.unavailable;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restorePurchases() async {}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+453
-156
@@ -1,210 +1,398 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../core/json_utils.dart';
|
||||
import '../models/app_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_theme.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'app_bottom_sheet.dart';
|
||||
import 'app_busy_indicator.dart';
|
||||
import 'surface_panel.dart';
|
||||
|
||||
Future<void> showHabitDialog(
|
||||
BuildContext context,
|
||||
ValueChanged<String> onSubmit, {
|
||||
Future<bool> Function(String value) onSubmit, {
|
||||
String initialValue = '',
|
||||
}) async {
|
||||
final controller = TextEditingController(text: initialValue);
|
||||
await showDialog<void>(
|
||||
final l10n = context.l10n;
|
||||
await showTextEntrySheet(
|
||||
context: context,
|
||||
title: initialValue.isEmpty ? l10n.addHabitTitle : l10n.renameHabitTitle,
|
||||
hintText: l10n.habitHint,
|
||||
initialValue: initialValue,
|
||||
icon: Icons.add_task_rounded,
|
||||
accentBuilder: (context) => FocusSeedVisuals.of(context).habit,
|
||||
onSubmit: onSubmit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showIntentionDialog(
|
||||
BuildContext context,
|
||||
Future<bool> Function(String value) onSubmit, {
|
||||
String initialValue = '',
|
||||
}) async {
|
||||
final l10n = context.l10n;
|
||||
await showTextEntrySheet(
|
||||
context: context,
|
||||
title:
|
||||
initialValue.isEmpty ? l10n.setIntentionTitle : l10n.editIntentionTitle,
|
||||
hintText: l10n.intentionHint,
|
||||
initialValue: initialValue,
|
||||
icon: Icons.spa_rounded,
|
||||
maxLength: 90,
|
||||
maxLines: 3,
|
||||
accentBuilder: (context) => FocusSeedVisuals.of(context).action,
|
||||
onSubmit: onSubmit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showTextEntrySheet({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String hintText,
|
||||
required Future<bool> Function(String value) onSubmit,
|
||||
required IconData icon,
|
||||
required Color Function(BuildContext context) accentBuilder,
|
||||
String initialValue = '',
|
||||
int? maxLength,
|
||||
int minLines = 1,
|
||||
int maxLines = 1,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(initialValue.isEmpty ? 'Add habit' : 'Rename habit'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
(context) => _TextEntrySheet(
|
||||
title: title,
|
||||
hintText: hintText,
|
||||
initialValue: initialValue,
|
||||
maxLength: maxLength,
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
icon: icon,
|
||||
accent: accentBuilder(context),
|
||||
onSubmit: onSubmit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _TextEntrySheet extends StatefulWidget {
|
||||
const _TextEntrySheet({
|
||||
required this.title,
|
||||
required this.hintText,
|
||||
required this.initialValue,
|
||||
required this.icon,
|
||||
required this.accent,
|
||||
required this.onSubmit,
|
||||
this.maxLength,
|
||||
this.minLines = 1,
|
||||
this.maxLines = 1,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String hintText;
|
||||
final String initialValue;
|
||||
final IconData icon;
|
||||
final Color accent;
|
||||
final Future<bool> Function(String value) onSubmit;
|
||||
final int? maxLength;
|
||||
final int minLines;
|
||||
final int maxLines;
|
||||
|
||||
@override
|
||||
State<_TextEntrySheet> createState() => _TextEntrySheetState();
|
||||
}
|
||||
|
||||
class _TextEntrySheetState extends State<_TextEntrySheet> {
|
||||
late final TextEditingController _controller;
|
||||
bool _saving = false;
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_saving) {
|
||||
return;
|
||||
}
|
||||
final value = _controller.text.trim();
|
||||
if (value.isEmpty) {
|
||||
setState(() => _errorText = context.l10n.textEntryRequired);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_errorText = null;
|
||||
});
|
||||
final saved = await widget.onSubmit(value);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (saved) {
|
||||
Navigator.pop(context);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_errorText = context.l10n.saveFailed;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final foreground =
|
||||
ThemeData.estimateBrightnessForColor(widget.accent) == Brightness.dark
|
||||
? Colors.white
|
||||
: scheme.onSurface;
|
||||
return AppBottomSheetScaffold(
|
||||
title: widget.title,
|
||||
icon: widget.icon,
|
||||
accent: widget.accent,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SurfacePanel(
|
||||
radius: 18,
|
||||
tone: SurfacePanelTone.paper,
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
maxLength: widget.maxLength,
|
||||
minLines: widget.minLines,
|
||||
maxLines: widget.maxLines,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Example: Read 10 pages',
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
onSubmit(value);
|
||||
Navigator.pop(context);
|
||||
textInputAction:
|
||||
widget.maxLines == 1
|
||||
? TextInputAction.done
|
||||
: TextInputAction.newline,
|
||||
onChanged: (_) {
|
||||
if (_errorText == null) {
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
setState(() => _errorText = null);
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
if (widget.maxLines == 1) {
|
||||
unawaited(_submit());
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
hintText: widget.hintText,
|
||||
errorText: _errorText,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onSubmit(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed:
|
||||
_saving || _controller.text.trim().isEmpty
|
||||
? null
|
||||
: _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: widget.accent,
|
||||
foregroundColor: foreground,
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
textStyle: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
child:
|
||||
_saving
|
||||
? AppBusyIndicator(
|
||||
semanticLabel: l10n.savingLabel,
|
||||
color: foreground,
|
||||
)
|
||||
: Text(l10n.save),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> confirmDeleteHabit(
|
||||
BuildContext context,
|
||||
VoidCallback onConfirm,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
await showConfirmSheet(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Delete habit?'),
|
||||
content: const Text(
|
||||
'Past completion records for this habit will be removed.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: context.l10n.deleteHabitTitle,
|
||||
body: context.l10n.deleteHabitBody,
|
||||
confirmLabel: context.l10n.delete,
|
||||
icon: Icons.delete_outline_rounded,
|
||||
accentBuilder: (context) => Theme.of(context).colorScheme.error,
|
||||
onConfirm: onConfirm,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showExportDialog(BuildContext context, AppState state) async {
|
||||
final controller = TextEditingController(text: prettyJson(state.toJson()));
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Export JSON'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
minLines: 8,
|
||||
maxLines: 12,
|
||||
readOnly: true,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: controller.text));
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
showSnack(context, 'Export copied');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Future<void> showImportDialog(
|
||||
Future<void> confirmClearToday(
|
||||
BuildContext context,
|
||||
ValueChanged<String> onImport,
|
||||
VoidCallback onConfirm,
|
||||
) async {
|
||||
final controller = TextEditingController();
|
||||
await showDialog<void>(
|
||||
await showConfirmSheet(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Import JSON'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
minLines: 8,
|
||||
maxLines: 12,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'Paste Focus Seed JSON export',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onImport(controller.text);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Import'),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: context.l10n.clearTodayDataTitle,
|
||||
body: context.l10n.clearTodayDataBody,
|
||||
confirmLabel: context.l10n.clearToday,
|
||||
icon: Icons.refresh_rounded,
|
||||
accentBuilder: (context) => FocusSeedVisuals.of(context).warning,
|
||||
onConfirm: onConfirm,
|
||||
);
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
Future<void> confirmClearData(
|
||||
BuildContext context,
|
||||
VoidCallback onConfirm,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
await showConfirmSheet(
|
||||
context: context,
|
||||
title: context.l10n.clearAllDataTitle,
|
||||
body: context.l10n.clearAllDataBody,
|
||||
confirmLabel: context.l10n.deleteAll,
|
||||
icon: Icons.delete_forever_rounded,
|
||||
accentBuilder: (context) => Theme.of(context).colorScheme.error,
|
||||
onConfirm: onConfirm,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showConfirmSheet({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String body,
|
||||
required String confirmLabel,
|
||||
required IconData icon,
|
||||
required Color Function(BuildContext context) accentBuilder,
|
||||
required VoidCallback onConfirm,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Clear local data?'),
|
||||
content: const Text(
|
||||
'This resets habits, focus sessions, and reflections on this device.',
|
||||
(context) => _ConfirmSheet(
|
||||
title: title,
|
||||
body: body,
|
||||
confirmLabel: confirmLabel,
|
||||
icon: icon,
|
||||
accent: accentBuilder(context),
|
||||
onConfirm: onConfirm,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
);
|
||||
}
|
||||
|
||||
class _ConfirmSheet extends StatelessWidget {
|
||||
const _ConfirmSheet({
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.confirmLabel,
|
||||
required this.icon,
|
||||
required this.accent,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String body;
|
||||
final String confirmLabel;
|
||||
final IconData icon;
|
||||
final Color accent;
|
||||
final VoidCallback onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return AppBottomSheetScaffold(
|
||||
title: title,
|
||||
icon: icon,
|
||||
accent: accent,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SurfacePanel(
|
||||
radius: 18,
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
|
||||
color: Color.alphaBlend(
|
||||
accent.withValues(alpha: 0.045),
|
||||
scheme.surfaceContainerLowest,
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
child: Text(
|
||||
body,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
height: 1.42,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_ConfirmActions(
|
||||
accent: accent,
|
||||
confirmLabel: confirmLabel,
|
||||
onConfirm: onConfirm,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showPrivacySheet(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder:
|
||||
(context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: const [
|
||||
Text(
|
||||
'Privacy',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800),
|
||||
(context) => AppBottomSheetScaffold(
|
||||
title: context.l10n.privacyTitle,
|
||||
subtitle: context.l10n.privacySubtitle,
|
||||
icon: Icons.lock_outline_rounded,
|
||||
accent: FocusSeedVisuals.of(context).privacy,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_PrivacyPoint(
|
||||
icon: Icons.phone_android_rounded,
|
||||
text: context.l10n.privacyBodyOne,
|
||||
),
|
||||
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),
|
||||
_PrivacyPoint(
|
||||
icon: Icons.cleaning_services_rounded,
|
||||
text: context.l10n.privacyBodyTwo,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -212,6 +400,115 @@ void showPrivacySheet(BuildContext context) {
|
||||
);
|
||||
}
|
||||
|
||||
void showSnack(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
class _PrivacyPoint extends StatelessWidget {
|
||||
const _PrivacyPoint({required this.icon, required this.text});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
return SurfacePanel(
|
||||
radius: 18,
|
||||
padding: const EdgeInsets.fromLTRB(13, 13, 13, 13),
|
||||
color: Color.alphaBlend(
|
||||
visuals.privacy.withValues(alpha: 0.045),
|
||||
scheme.surfaceContainerLowest,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: visuals.privacy, size: 21),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
height: 1.42,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showSnack(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 92),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ConfirmActions extends StatelessWidget {
|
||||
const _ConfirmActions({
|
||||
required this.accent,
|
||||
required this.confirmLabel,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
final Color accent;
|
||||
final String confirmLabel;
|
||||
final VoidCallback onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final foreground =
|
||||
ThemeData.estimateBrightnessForColor(accent) == Brightness.dark
|
||||
? Colors.white
|
||||
: scheme.onSurface;
|
||||
final confirmButton = FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor: foreground,
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(confirmLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
final cancelButton = TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.cancel),
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1);
|
||||
if (constraints.maxWidth < 330 || textScale > 1.25) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [confirmButton, const SizedBox(height: 8), cancelButton],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: cancelButton),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(flex: 2, child: confirmButton),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,10 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_theme.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'app_motion.dart';
|
||||
|
||||
class LoadingView extends StatelessWidget {
|
||||
const LoadingView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
final theme = Theme.of(context);
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final l10n = context.l10n;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_LoadingMark(color: visuals.focus),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
l10n.loadingLocalData,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingMark extends StatefulWidget {
|
||||
const _LoadingMark({required this.color});
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
State<_LoadingMark> createState() => _LoadingMarkState();
|
||||
}
|
||||
|
||||
class _LoadingMarkState extends State<_LoadingMark>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1100),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (AppMotion.reduced(context)) {
|
||||
_controller.stop();
|
||||
_controller.value = 1;
|
||||
return;
|
||||
}
|
||||
if (!_controller.isAnimating) {
|
||||
_controller.repeat(reverse: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (AppMotion.reduced(context)) {
|
||||
return CustomPaint(
|
||||
size: const Size.square(44),
|
||||
painter: _LoadingMarkPainter(color: widget.color, progress: 1),
|
||||
);
|
||||
}
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final progress =
|
||||
0.58 + Curves.easeInOut.transform(_controller.value) * 0.42;
|
||||
return CustomPaint(
|
||||
size: const Size.square(44),
|
||||
painter: _LoadingMarkPainter(color: widget.color, progress: progress),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingMarkPainter extends CustomPainter {
|
||||
const _LoadingMarkPainter({required this.color, required this.progress});
|
||||
|
||||
final Color color;
|
||||
final double progress;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = size.center(Offset.zero);
|
||||
final seedRect = Rect.fromCenter(
|
||||
center: center,
|
||||
width: size.width * (0.52 + progress * 0.08),
|
||||
height: size.height * 0.42,
|
||||
);
|
||||
canvas.drawOval(seedRect, Paint()..color = color.withValues(alpha: 0.13));
|
||||
canvas.drawOval(
|
||||
seedRect.deflate(1),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.8
|
||||
..color = color.withValues(alpha: 0.28 + progress * 0.35),
|
||||
);
|
||||
final path =
|
||||
Path()
|
||||
..moveTo(size.width * 0.28, size.height * 0.54)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.48,
|
||||
size.height * 0.40,
|
||||
size.width * 0.72,
|
||||
size.height * 0.48,
|
||||
);
|
||||
final metric = path.computeMetrics().first;
|
||||
canvas.drawPath(
|
||||
metric.extractPath(0, metric.length * progress.clamp(0.08, 1)),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.6
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _LoadingMarkPainter oldDelegate) {
|
||||
return oldDelegate.color != color || oldDelegate.progress != progress;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SettingsTile extends StatelessWidget {
|
||||
const SettingsTile({
|
||||
import 'workbench_ui.dart';
|
||||
|
||||
class SettingsSection extends StatelessWidget {
|
||||
const SettingsSection({
|
||||
required this.title,
|
||||
required this.children,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
WorkbenchPanel(
|
||||
accent: Theme.of(context).colorScheme.primary,
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) const Divider(indent: 16, endIndent: 16, height: 1),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsRow extends StatelessWidget {
|
||||
const SettingsRow({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
this.destructive = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final bool destructive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = destructive ? Theme.of(context).colorScheme.error : null;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon, color: color),
|
||||
title: Text(title, style: TextStyle(color: color)),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 10, 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.11),
|
||||
borderRadius: BorderRadius.circular(kWorkbenchRadius),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_theme.dart';
|
||||
import 'pressable_scale.dart';
|
||||
|
||||
enum SurfacePanelTone { plain, paper, wash }
|
||||
|
||||
class SurfacePanel extends StatelessWidget {
|
||||
const SurfacePanel({
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
this.margin,
|
||||
this.color,
|
||||
this.borderColor,
|
||||
this.tone = SurfacePanelTone.plain,
|
||||
this.radius = 12,
|
||||
this.elevated = false,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Color? color;
|
||||
final Color? borderColor;
|
||||
final SurfacePanelTone tone;
|
||||
final double radius;
|
||||
final bool elevated;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final effectiveColor =
|
||||
color ??
|
||||
switch (tone) {
|
||||
SurfacePanelTone.plain => scheme.surfaceContainerLowest,
|
||||
SurfacePanelTone.paper =>
|
||||
scheme.brightness == Brightness.dark
|
||||
? scheme.surfaceContainerLow
|
||||
: AppTheme.paper,
|
||||
SurfacePanelTone.wash =>
|
||||
scheme.brightness == Brightness.dark
|
||||
? scheme.surfaceContainerLow
|
||||
: scheme.surfaceContainerLowest,
|
||||
};
|
||||
final effectiveBorderColor =
|
||||
borderColor ??
|
||||
(tone == SurfacePanelTone.wash
|
||||
? visuals.focus.withValues(alpha: 0.06)
|
||||
: scheme.outlineVariant.withValues(alpha: 0.0));
|
||||
final decoration = BoxDecoration(
|
||||
color: effectiveColor,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
border:
|
||||
effectiveBorderColor.a == 0
|
||||
? null
|
||||
: Border.all(color: effectiveBorderColor),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: visuals.shadow.withValues(
|
||||
alpha:
|
||||
theme.colorScheme.brightness == Brightness.dark
|
||||
? elevated
|
||||
? 0.22
|
||||
: 0.11
|
||||
: elevated
|
||||
? 0.11
|
||||
: 0.045,
|
||||
),
|
||||
blurRadius: elevated ? 22 : 12,
|
||||
offset: Offset(0, elevated ? 8 : 4),
|
||||
),
|
||||
],
|
||||
);
|
||||
final content = Padding(padding: padding, child: child);
|
||||
return PressableScale(
|
||||
enabled: onTap != null,
|
||||
child: Container(
|
||||
margin: margin,
|
||||
decoration: decoration,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child:
|
||||
onTap == null
|
||||
? content
|
||||
: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(onTap: onTap, child: content),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+382
-1
@@ -1,6 +1,30 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "88.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "8.1.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -17,6 +41,54 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.15.0"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "8.12.6"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -25,6 +97,30 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +137,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -49,6 +161,30 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.31.0"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.31.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -73,6 +209,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -86,6 +230,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 +245,62 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.19.0"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -128,6 +333,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -152,14 +365,54 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
path:
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.19"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -200,6 +453,38 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
recase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: recase
|
||||
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -256,11 +541,35 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -269,6 +578,30 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.5.42"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.43.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -285,6 +618,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -309,6 +650,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -325,6 +674,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "14.3.1"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -333,6 +690,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -341,6 +714,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.7.2 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
|
||||
+15
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.1+2
|
||||
version: 1.0.2+3
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.2
|
||||
@@ -30,15 +30,24 @@ 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
|
||||
drift: ^2.31.0
|
||||
sqlite3_flutter_libs: ^0.5.42
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
build_runner: ^2.10.4
|
||||
drift_dev: ^2.31.0
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
@@ -52,12 +61,17 @@ 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/illustrations/onboarding/
|
||||
- assets/illustrations/records/
|
||||
|
||||
# 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,177 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_seed/src/application/focus_seed_controller.dart';
|
||||
import 'package:focus_seed/src/core/date_keys.dart';
|
||||
import 'package:focus_seed/src/data/focus_seed_store.dart';
|
||||
import 'package:focus_seed/src/data/local/focus_seed_database.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/habit.dart';
|
||||
import 'package:focus_seed/src/models/mood.dart';
|
||||
import 'package:focus_seed/src/models/reflection_entry.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('clearToday removes only current-day progress', () async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
final today = todayKey();
|
||||
final yesterday = dateKey(DateTime.now().subtract(const Duration(days: 1)));
|
||||
final habit = Habit(
|
||||
id: 'habit-1',
|
||||
title: 'Keep scope clear',
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 2)),
|
||||
completedDates: {today, yesterday},
|
||||
);
|
||||
await store.save(
|
||||
AppState(
|
||||
anchor: 'Finish the cleanup',
|
||||
anchorDateKey: today,
|
||||
habits: [habit],
|
||||
sessions: [
|
||||
FocusSession(
|
||||
id: 'session-today',
|
||||
minutes: 25,
|
||||
completedAt: DateTime.now(),
|
||||
),
|
||||
FocusSession(
|
||||
id: 'session-yesterday',
|
||||
minutes: 15,
|
||||
completedAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||
),
|
||||
],
|
||||
reflections: [
|
||||
ReflectionEntry(
|
||||
id: 'reflection-today',
|
||||
dateKey: today,
|
||||
note: 'Today note',
|
||||
mood: Mood.clear,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
ReflectionEntry(
|
||||
id: 'reflection-yesterday',
|
||||
dateKey: yesterday,
|
||||
note: 'Yesterday note',
|
||||
mood: Mood.calm,
|
||||
createdAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final controller = FocusSeedController(store: store);
|
||||
await controller.load();
|
||||
|
||||
final cleared = await controller.clearToday();
|
||||
final state = controller.state!;
|
||||
|
||||
expect(cleared, isTrue);
|
||||
expect(state.activeAnchor(today), isEmpty);
|
||||
expect(state.habits.single.completedDates, {yesterday});
|
||||
expect(state.sessions.map((session) => session.id), ['session-yesterday']);
|
||||
expect(state.reflections.map((reflection) => reflection.id), [
|
||||
'reflection-yesterday',
|
||||
]);
|
||||
});
|
||||
|
||||
test('default focus duration persists and survives record cleanup', () async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
final controller = FocusSeedController(store: store);
|
||||
await controller.load();
|
||||
|
||||
final changed = await controller.setDefaultFocusMinutes(45);
|
||||
expect(changed, isTrue);
|
||||
expect(controller.state!.defaultFocusMinutes, 45);
|
||||
|
||||
final reloaded = FocusSeedController(store: store);
|
||||
await reloaded.load();
|
||||
expect(reloaded.state!.defaultFocusMinutes, 45);
|
||||
|
||||
final cleared = await reloaded.clearData();
|
||||
expect(cleared, isTrue);
|
||||
expect(reloaded.state!.defaultFocusMinutes, 45);
|
||||
});
|
||||
|
||||
test('completed focus sessions snapshot the active daily focus', () async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
final controller = FocusSeedController(store: store);
|
||||
await controller.load();
|
||||
|
||||
final savedFocus = await controller.setTodayAnchor('Review launch notes');
|
||||
final savedSession = await controller.completeFocusSession(
|
||||
10,
|
||||
actualSeconds: 60,
|
||||
);
|
||||
|
||||
expect(savedFocus, isTrue);
|
||||
expect(savedSession, isTrue);
|
||||
expect(controller.state!.sessions.single.intention, 'Review launch notes');
|
||||
|
||||
final reloaded = FocusSeedController(store: store);
|
||||
await reloaded.load();
|
||||
expect(reloaded.state!.sessions.single.intention, 'Review launch notes');
|
||||
});
|
||||
|
||||
test('progress notes can be backfilled for a past record date', () async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
final controller = FocusSeedController(store: store);
|
||||
await controller.load();
|
||||
|
||||
const pastDate = '2026-06-12';
|
||||
final saved = await controller.saveReflectionForDate(
|
||||
pastDate,
|
||||
'Recovered the next step.',
|
||||
Mood.clear,
|
||||
);
|
||||
|
||||
expect(saved, isTrue);
|
||||
expect(controller.state!.reflections.single.dateKey, pastDate);
|
||||
expect(
|
||||
controller.state!.reflections.single.note,
|
||||
'Recovered the next step.',
|
||||
);
|
||||
});
|
||||
|
||||
test('today anchor does not create a daily action object', () async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
final controller = FocusSeedController(store: store);
|
||||
await controller.load();
|
||||
|
||||
final savedToday = await controller.setTodayAnchor('Review launch notes');
|
||||
|
||||
expect(savedToday, isTrue);
|
||||
expect(controller.state!.activeAnchor(todayKey()), 'Review launch notes');
|
||||
expect(controller.state!.actions, isEmpty);
|
||||
});
|
||||
|
||||
test('focus can complete without a today anchor', () async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
final controller = FocusSeedController(store: store);
|
||||
await controller.load();
|
||||
|
||||
final savedSession = await controller.completeFocusSession(
|
||||
10,
|
||||
actualSeconds: 120,
|
||||
);
|
||||
|
||||
expect(savedSession, isTrue);
|
||||
expect(controller.state!.sessions.single.intention, isEmpty);
|
||||
expect(controller.state!.actions, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_seed/src/data/focus_seed_store.dart';
|
||||
import 'package:focus_seed/src/data/local/focus_seed_database.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/habit.dart';
|
||||
import 'package:focus_seed/src/models/mood.dart';
|
||||
import 'package:focus_seed/src/models/reflection_entry.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('new installs seed localized starter habits into sqlite', () async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
|
||||
final state = await store.load(
|
||||
defaultHabitTitles: const ['Plan', 'Focus', 'Reflect'],
|
||||
);
|
||||
|
||||
expect(state.anchor, isEmpty);
|
||||
expect(state.habits.map((habit) => habit.title), [
|
||||
'Plan',
|
||||
'Focus',
|
||||
'Reflect',
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'sqlite snapshot round-trips focus, habit, and reflection data',
|
||||
() async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
final now = DateTime(2026, 6, 12, 10, 30);
|
||||
final state = AppState(
|
||||
anchor: 'Ship the local database',
|
||||
anchorDateKey: '2026-06-12',
|
||||
habits: [
|
||||
Habit(
|
||||
id: 'habit-1',
|
||||
title: 'Review the plan',
|
||||
createdAt: now.subtract(const Duration(days: 2)),
|
||||
completedDates: const {'2026-06-11', '2026-06-12'},
|
||||
),
|
||||
],
|
||||
sessions: [
|
||||
FocusSession(
|
||||
id: 'session-1',
|
||||
minutes: 25,
|
||||
actualSeconds: 90,
|
||||
completedAt: now,
|
||||
intention: 'Ship the local database',
|
||||
),
|
||||
],
|
||||
reflections: [
|
||||
ReflectionEntry(
|
||||
id: 'reflection-1',
|
||||
dateKey: '2026-06-12',
|
||||
note: 'Small scope kept it clean.',
|
||||
mood: Mood.clear,
|
||||
createdAt: now.add(const Duration(minutes: 1)),
|
||||
),
|
||||
],
|
||||
defaultFocusMinutes: 45,
|
||||
);
|
||||
|
||||
await store.save(state);
|
||||
final loaded = await store.load();
|
||||
|
||||
expect(loaded.anchor, 'Ship the local database');
|
||||
expect(loaded.anchorDateKey, '2026-06-12');
|
||||
expect(loaded.habits.single.completedDates, {'2026-06-11', '2026-06-12'});
|
||||
expect(loaded.sessions.single.minutes, 25);
|
||||
expect(loaded.sessions.single.actualSeconds, 90);
|
||||
expect(loaded.sessions.single.actualMinutes, 2);
|
||||
expect(loaded.sessions.single.intention, 'Ship the local database');
|
||||
expect(loaded.reflections.single.note, 'Small scope kept it clean.');
|
||||
expect(loaded.defaultFocusMinutes, 45);
|
||||
final row = await database.select(database.focusSessionRows).getSingle();
|
||||
expect(row.plannedMinutes, 25);
|
||||
expect(row.actualSeconds, 90);
|
||||
expect(row.intentionText, 'Ship the local database');
|
||||
},
|
||||
);
|
||||
|
||||
test('legacy shared preferences json migrates once and is removed', () async {
|
||||
final legacyState = AppState(
|
||||
anchor: 'Migrate safely',
|
||||
anchorDateKey: '2026-06-12',
|
||||
habits: [
|
||||
Habit(
|
||||
id: 'habit-legacy',
|
||||
title: 'Keep existing data',
|
||||
createdAt: DateTime(2026, 6, 10),
|
||||
completedDates: const {'2026-06-12'},
|
||||
),
|
||||
],
|
||||
sessions: [
|
||||
FocusSession(
|
||||
id: 'session-legacy',
|
||||
minutes: 15,
|
||||
completedAt: DateTime(2026, 6, 12, 8),
|
||||
),
|
||||
],
|
||||
reflections: const [],
|
||||
);
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'focus_seed_state_v1': jsonEncode(legacyState.toJson()),
|
||||
});
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
|
||||
final migrated = await store.load();
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
|
||||
expect(migrated.anchor, 'Migrate safely');
|
||||
expect(migrated.habits.single.title, 'Keep existing data');
|
||||
expect(migrated.sessions.single.minutes, 15);
|
||||
expect(preferences.getString('focus_seed_state_v1'), isNull);
|
||||
});
|
||||
}
|
||||
+477
-4
@@ -1,16 +1,489 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_seed/src/app/focus_seed_app.dart';
|
||||
import 'package:focus_seed/src/data/focus_seed_store.dart';
|
||||
import 'package:focus_seed/src/data/local/focus_seed_database.dart';
|
||||
import 'package:focus_seed/src/models/app_state.dart';
|
||||
import 'package:focus_seed/src/models/focus_session.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Focus Seed opens to the today screen', (tester) async {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
await tester.pumpWidget(const FocusSeedApp());
|
||||
test('locale policy defaults to English for release and Chinese for dev', () {
|
||||
expect(focusSeedDefaultLocale(isRelease: true), const Locale('en'));
|
||||
expect(focusSeedDefaultLocale(), const Locale('zh'));
|
||||
});
|
||||
|
||||
test('welcome art uses managed onboarding assets', () {
|
||||
final welcomeSource =
|
||||
File(
|
||||
'lib/src/features/onboarding/welcome_screen.dart',
|
||||
).readAsStringSync();
|
||||
final landingSource =
|
||||
File(
|
||||
'lib/src/features/onboarding/landing_setup_screen.dart',
|
||||
).readAsStringSync();
|
||||
final recordsSource =
|
||||
File('lib/src/features/records/records_screen.dart').readAsStringSync();
|
||||
|
||||
expect(welcomeSource, contains('Image.asset'));
|
||||
expect(welcomeSource, contains('AppAssets.welcomePath'));
|
||||
expect(welcomeSource, contains('AppAssets.focusBlock'));
|
||||
expect(welcomeSource, contains('AppAssets.reflectionNote'));
|
||||
expect(landingSource, contains('AppAssets.landingFocusStart'));
|
||||
expect(recordsSource, contains('AppAssets.recordsEmpty'));
|
||||
expect(welcomeSource, isNot(contains('KineticIllustration')));
|
||||
expect(welcomeSource, isNot(contains('SingleChildScrollView')));
|
||||
expect(welcomeSource, isNot(contains('Parallax')));
|
||||
expect(landingSource, isNot(contains('KineticIllustration')));
|
||||
expect(recordsSource, isNot(contains('KineticIllustration')));
|
||||
expect(welcomeSource, isNot(contains('SeedIllustration')));
|
||||
expect(welcomeSource, isNot(contains('MiniTerrainMark')));
|
||||
});
|
||||
|
||||
testWidgets('Today keeps the core loop free of saved plan noise', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpFocusSeedApp(tester, locale: const Locale('en'));
|
||||
|
||||
expect(find.text('Focus Seed'), findsOneWidget);
|
||||
expect(find.text('Start 10 min'), findsWidgets);
|
||||
expect(find.text('Name today\'s focus'), findsOneWidget);
|
||||
expect(find.text('Pick a focus plan'), findsNothing);
|
||||
expect(find.text('Deep work sprint'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('first launch shows welcome before Today', (tester) async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
await tester.pumpWidget(
|
||||
FocusSeedApp(
|
||||
locale: const Locale('en'),
|
||||
focusSeedStore: FocusSeedStore(database: database),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text('Plant just one thing each day.'), findsOneWidget);
|
||||
expect(find.text('Continue'), findsOneWidget);
|
||||
expect(find.byType(Image), findsWidgets);
|
||||
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.text('Protect 10 minutes first.'), findsOneWidget);
|
||||
expect(find.byType(Image), findsWidgets);
|
||||
|
||||
await tester.tap(find.text('Continue'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.text('Leave one progress note.'), findsOneWidget);
|
||||
expect(find.byType(Image), findsWidgets);
|
||||
|
||||
await tester.tap(find.text('Set today\'s focus'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
expect(find.text('Start with 10 minutes.'), findsOneWidget);
|
||||
expect(find.text('Write down what you will do'), findsOneWidget);
|
||||
expect(find.text('What do you want to do first?'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Write down what you will do'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('What do you want to do first?'), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.text('Start 10 minutes'));
|
||||
await tester.pumpAndSettle();
|
||||
final enterTodayButton = find.ancestor(
|
||||
of: find.text('Start 10 minutes'),
|
||||
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
|
||||
);
|
||||
expect(enterTodayButton, findsOneWidget);
|
||||
expect(
|
||||
(tester.widget(enterTodayButton) as ButtonStyleButton).onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
await tester.tap(enterTodayButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Deep work in progress'), findsOneWidget);
|
||||
expect(find.text('Start this session first'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('landing setup can save typed action before starting', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'focus_seed_onboarding_complete': true,
|
||||
'focus_seed_landing_setup_complete': false,
|
||||
});
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
await tester.pumpWidget(
|
||||
FocusSeedApp(
|
||||
locale: const Locale('en'),
|
||||
focusSeedStore: FocusSeedStore(database: database),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Write down what you will do'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byType(TextField),
|
||||
'Finish the launch checklist',
|
||||
);
|
||||
await tester.ensureVisible(find.text('Start 10 minutes'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Start 10 minutes'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Today\'s focus: Finish the launch checklist'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('10 min'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('finished onboarding restores landing until it is completed', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'focus_seed_onboarding_complete': true,
|
||||
'focus_seed_landing_setup_complete': false,
|
||||
});
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
await tester.pumpWidget(
|
||||
FocusSeedApp(
|
||||
locale: const Locale('en'),
|
||||
focusSeedStore: FocusSeedStore(database: database),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Start with 10 minutes.'), findsOneWidget);
|
||||
expect(find.text('Focus Seed'), findsNothing);
|
||||
|
||||
await tester.ensureVisible(find.text('Enter Today'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Enter Today'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Focus Seed'), findsOneWidget);
|
||||
expect(find.text('Today anchor'), findsOneWidget);
|
||||
expect(find.text('Start focus'), findsOneWidget);
|
||||
expect(find.text('Start with 10 minutes.'), findsNothing);
|
||||
|
||||
await tester.pumpWidget(
|
||||
FocusSeedApp(
|
||||
locale: const Locale('en'),
|
||||
focusSeedStore: FocusSeedStore(database: database),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Focus Seed'), findsOneWidget);
|
||||
expect(find.text('Start with 10 minutes.'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('bottom navigation exposes Focus, Records, and Me', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpFocusSeedApp(tester, locale: const Locale('en'));
|
||||
|
||||
await tester.tap(_navDestination('Focus'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Ready when you are'), findsOneWidget);
|
||||
|
||||
await tester.tap(_navDestination('Records'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Records'), findsWidgets);
|
||||
expect(find.text('No progress records yet'), findsOneWidget);
|
||||
|
||||
await tester.tap(_navDestination('Me'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Me'), findsWidgets);
|
||||
expect(find.text('Preferences'), findsWidgets);
|
||||
|
||||
await tester.tap(_navDestination('Today'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Name today\'s focus'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('V1 action loop records a session and capture', (tester) async {
|
||||
await pumpFocusSeedApp(tester, locale: const Locale('en'));
|
||||
|
||||
final startButton = find.ancestor(
|
||||
of: find.text('Start 10 min').last,
|
||||
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
|
||||
);
|
||||
await tester.ensureVisible(startButton);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(startButton);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Deep work in progress'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Pause'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Paused - your place is kept'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Resume'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Deep work in progress'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Complete session'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Session complete'), findsOneWidget);
|
||||
expect(find.text('Write progress note'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Write progress note'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Leave one progress note'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).last, 'Finished one pass.');
|
||||
await tester.tap(find.text('Save note'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(_navDestination('Records'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Records'), findsWidgets);
|
||||
expect(find.text('Finished one pass.'), findsOneWidget);
|
||||
expect(find.text('No progress records yet'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Records can backfill reflection for a past daily record', (
|
||||
tester,
|
||||
) async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
final now = DateTime.now();
|
||||
final yesterday = now.subtract(const Duration(days: 1));
|
||||
await store.save(
|
||||
AppState(
|
||||
anchor: '',
|
||||
anchorDateKey: '',
|
||||
habits: const [],
|
||||
sessions: [
|
||||
FocusSession(
|
||||
id: 'session-yesterday',
|
||||
minutes: 10,
|
||||
actualSeconds: 600,
|
||||
completedAt: yesterday,
|
||||
intention: 'Draft launch notes',
|
||||
),
|
||||
],
|
||||
reflections: const [],
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
FocusSeedApp(
|
||||
locale: const Locale('en'),
|
||||
focusSeedStore: store,
|
||||
showWelcomeOnFirstRun: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(_navDestination('Records'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Draft launch notes'), findsWidgets);
|
||||
expect(find.text('No progress note yet'), findsWidgets);
|
||||
expect(find.text('Write one sentence to close today'), findsOneWidget);
|
||||
expect(find.text('Continue this'), findsNothing);
|
||||
|
||||
final reflectionButton = find.ancestor(
|
||||
of: find.text('Write one sentence to close today'),
|
||||
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
|
||||
);
|
||||
await tester.ensureVisible(reflectionButton);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(reflectionButton);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Leave one progress note'), findsOneWidget);
|
||||
expect(find.text('TODAY\'S FOCUS'), findsWidgets);
|
||||
expect(find.text('Draft launch notes'), findsWidgets);
|
||||
await tester.enterText(find.byType(TextField).last, 'Next: polish copy.');
|
||||
final saveNoteButton = find.ancestor(
|
||||
of: find.text('Save note'),
|
||||
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
|
||||
);
|
||||
await tester.ensureVisible(saveNoteButton);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(saveNoteButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Next: polish copy.'), findsOneWidget);
|
||||
expect(find.text('Write one sentence to close today'), findsNothing);
|
||||
expect(find.text('Continue this'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('settings manages local data without JSON tools', (tester) async {
|
||||
await pumpFocusSeedApp(tester, locale: const Locale('en'));
|
||||
|
||||
await tester.tap(_navDestination('Me'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
var meScroll = find.byType(Scrollable).first;
|
||||
expect(find.text('Personal rhythm'), findsNothing);
|
||||
expect(find.text('Saved plans'), findsNothing);
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Theme, language, and default focus'),
|
||||
180,
|
||||
scrollable: meScroll,
|
||||
);
|
||||
final preferencesRow = find.ancestor(
|
||||
of: find.text('Theme, language, and default focus'),
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
expect(preferencesRow, findsOneWidget);
|
||||
await tester.tap(preferencesRow);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Default focus'), findsOneWidget);
|
||||
expect(find.text('10 min'), findsWidgets);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('5'), findsOneWidget);
|
||||
expect(find.text('45'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.add_rounded).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Custom length'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), '7');
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('7 min'), findsOneWidget);
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
meScroll = find.byType(Scrollable).first;
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Local records'),
|
||||
180,
|
||||
scrollable: meScroll,
|
||||
);
|
||||
expect(find.text('Local records'), findsOneWidget);
|
||||
expect(find.text('Clear today'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Delete all data'),
|
||||
180,
|
||||
scrollable: meScroll,
|
||||
);
|
||||
expect(find.text('Delete all data'), findsOneWidget);
|
||||
expect(find.text('Export JSON'), findsNothing);
|
||||
expect(find.text('Import JSON'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Records shows empty focus and reflection states', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpFocusSeedApp(tester, locale: const Locale('en'));
|
||||
|
||||
await tester.tap(_navDestination('Records'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Records'), findsWidgets);
|
||||
expect(find.text('No progress records yet'), findsOneWidget);
|
||||
expect(find.text('Back to Today'), 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 pumpFocusSeedApp(tester, locale: const Locale('en'));
|
||||
|
||||
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 pumpFocusSeedApp(tester);
|
||||
|
||||
expect(find.text('Focus Seed'), findsOneWidget);
|
||||
expect(
|
||||
find.text('\u5199\u4e0b\u4eca\u5929\u7684\u91cd\u70b9'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('\u5f00\u59cb 10 \u5206\u949f'), findsWidgets);
|
||||
expect(
|
||||
find.text('\u9009\u62e9\u4e00\u4e2a\u4e13\u6ce8\u8ba1\u5212'),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('\u6df1\u5ea6\u5de5\u4f5c'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('development language switch changes the active locale', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpFocusSeedApp(tester);
|
||||
|
||||
await tester.tap(_navDestination('\u6211\u7684'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final settingsScroll = find.byType(Scrollable).first;
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('\u4e3b\u9898\u3001\u8bed\u8a00\u548c\u9ed8\u8ba4\u4e13\u6ce8'),
|
||||
180,
|
||||
scrollable: settingsScroll,
|
||||
);
|
||||
final preferencesRow = find.ancestor(
|
||||
of: find.text(
|
||||
'\u4e3b\u9898\u3001\u8bed\u8a00\u548c\u9ed8\u8ba4\u4e13\u6ce8',
|
||||
),
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
await tester.tap(preferencesRow);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('\u8bed\u8a00'), findsOneWidget);
|
||||
final languageRow = find.ancestor(
|
||||
of: find.text('\u8bed\u8a00'),
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
expect(languageRow, findsOneWidget);
|
||||
await tester.tap(languageRow);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('English'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Language'), findsOneWidget);
|
||||
expect(find.text('English'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> pumpFocusSeedApp(WidgetTester tester, {Locale? locale}) async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
await tester.pumpWidget(
|
||||
FocusSeedApp(
|
||||
locale: locale,
|
||||
focusSeedStore: FocusSeedStore(database: database),
|
||||
showWelcomeOnFirstRun: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Finder _navDestination(String label) {
|
||||
final key = switch (label) {
|
||||
'Today' || '\u4eca\u65e5' => 'today',
|
||||
'Focus' || '\u4e13\u6ce8' => 'focus',
|
||||
'Records' || '\u8bb0\u5f55' => 'records',
|
||||
'Me' || '\u6211\u7684' => 'me',
|
||||
_ => label.toLowerCase(),
|
||||
};
|
||||
return find.byKey(ValueKey('focus-seed-nav-$key'));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user