Compare commits

..
10 Commits
77 changed files with 17506 additions and 1716 deletions
+10 -8
View File
@@ -1,14 +1,16 @@
# Focus Seed # 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. ## Current MVP Loop
2. Complete a focused session.
3. Check off small habits. 1. Choose or continue one meaningful action.
4. Save a one-minute reflection. 2. Complete a focused/action session.
5. Review the seven-day pulse. 3. Capture a short record.
4. Review accumulated records.
5. Grow toward calendar, goals, tags, trends, and user insight over time.
## Architecture ## Architecture
@@ -26,7 +28,7 @@ See `launch/architecture.md` for the complete responsibility map.
## Privacy ## 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 ## Development
+35
View File
@@ -0,0 +1,35 @@
# Focus Seed 1.0.1 Design QA
## Inputs
- Selected reference: `docs/design/focus-seed-visual-direction-v1.0.1.png`
- Ambience reference: `docs/design/focus-seed-living-ambience-v1.0.1.png`
- Device: Pixel 5 Android emulator, 1080 x 2340, density 440
## Captured States
- Empty Today: `launch/screenshots/focus-seed-101-today-v2.png`
- Intention saved: `launch/screenshots/focus-seed-101-intention.png`
- Running timer: `launch/screenshots/focus-seed-101-running-final.png`
- Background restored: `launch/screenshots/focus-seed-101-restored-final.png`
- Journey: `launch/screenshots/focus-seed-101-journey.png`
- Dark appearance: `launch/screenshots/focus-seed-101-dark.png`
## Findings
- P0 fixed: the initial focus card stacked vertically and read as oversized. The normal-phone layout now keeps time and growth side by side and reduces empty-state copy.
- P0 fixed: light status-bar icons lacked contrast. System UI brightness now follows app brightness.
- P1 fixed: intention dialogs disposed their controller during route dismissal. Dialog fields now own their value without an early-dispose race.
- P1 passed: timer restores from the absolute end time after backgrounding.
- P1 passed: content scrolls above persistent navigation without overlap.
- P1 passed: Journey communicates lifecycle order and incomplete states without relying on color alone.
- P1 passed: dark appearance preserves hierarchy and control contrast.
- P1 passed: a 320 x 700 viewport at 200% text scale renders without framework overflow or exception.
- P2 passed: empty, populated, running, restored, completed-habit, and dark states use the same component grammar.
## Residual P3 Notes
- English is the only in-app language in 1.0.1; future localization should use Flutter ARB generation before adding translated store regions.
- Tablet-specific two-column composition is deferred; the current layout remains centered and scrollable on larger widths.
final result: passed
+59
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
arb-dir: lib/src/l10n
template-arb-file: app_en.arb
output-dir: lib/src/l10n/generated
output-localization-file: app_localizations.dart
nullable-getter: false
synthetic-package: false
+54 -60
View File
@@ -1,6 +1,6 @@
# Focus Seed Architecture # 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 ## Directory Layout
@@ -8,77 +8,71 @@ Status: refactored from single-file prototype into modular Flutter structure.
lib/ lib/
main.dart main.dart
src/ src/
app/ app/ app shell, theme, ambience, localization setup
focus_seed_app.dart application/ product controllers and timer lifecycle
app_theme.dart core/ date keys, focus durations, ambience, utilities
core/ data/ local persistence boundary
date_keys.dart data/local/ Drift SQLite database
iterable_extensions.dart models/ immutable product models and derived state
json_utils.dart services/ no-op analytics and monetization interfaces
data/ features/ Today, Focus, Records, Me, habits, onboarding, reflection, shell
focus_seed_store.dart widgets/ reusable panels, dialogs, headers, loading, illustrations
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
``` ```
## Responsibility Boundaries ## Responsibility Boundaries
- `main.dart`: process entry only. - `main.dart`: process entry only.
- `src/app`: app shell and theme. - `src/app`: app-level theme, locale policy, ambience updates, and shell bootstrapping.
- `src/models`: serializable domain objects. - `src/application`: user commands, save rollback, local analytics events, and timer lifecycle.
- `src/data`: persistence boundary using `shared_preferences`. - `src/core`: framework-light utilities and product constants.
- `src/services`: no-op interfaces for analytics and monetization. - `src/data`: persistence boundary used by controllers.
- `src/features/shell`: state orchestration and feature routing. - `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/features/*`: screen-level UI and user flows.
- `src/widgets`: reusable UI components and dialogs. - `src/widgets`: reusable UI primitives.
- `src/core`: framework-light utilities.
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 ## Current Design Decisions
- No real ad SDK in V1. - No real ad SDK in V1.
- No real billing SDK in V1. - No real billing SDK in V1.
- No analytics SDK in V1. - No analytics SDK in V1.
- No network dependency. - No account, server, or network dependency.
- JSON import/export uses copy/paste to avoid file permissions. - Default focus duration is a user-facing local setting.
- Shell owns state until a future state-management package is justified. - Clear today resets only today's loop.
- Delete all data clears local product records while preserving app preference behavior where appropriate.
## Upgrade Path ## Upgrade Path
Next modularization steps when needed: - 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.
- Move `src/app`, `src/core`, `src/models`, `src/services`, and `src/widgets` into shared packages. - Add Riverpod or a repository package only when multiple apps or features need shared state patterns.
- Replace shell state with Riverpod once multiple apps/features share the same state patterns. - Add real analytics, ads, billing, remote config, or sync only with privacy, Data safety, policy, and tests in the same release.
- Add real `AnalyticsProvider`, `MonetizationProvider`, and remote config implementations only with privacy/Data safety updates. - Add golden or screenshot tests before scaling launch screenshots or visual variants.
- Add golden tests for key screens before scaling the app factory.
+22 -51
View File
@@ -31,11 +31,11 @@ Official references:
2. Publish an internal testing release. 2. Publish an internal testing release.
- Upload `build/app/outputs/bundle/release/app-release.aab`. - 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: - Release notes:
```text ```text
First internal test release. Internal test release for Focus Seed.
Includes focus timer, habit tracking, daily reflection, local progress stats, JSON export/import, and local privacy controls. 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. - Add yourself and one or two trusted accounts if available.
- Use this to confirm Play delivery and installation. - Use this to confirm Play delivery and installation.
@@ -45,11 +45,11 @@ Official references:
- Create track name: `Focus Seed V1 closed test`. - Create track name: `Focus Seed V1 closed test`.
- Countries/regions: use English-first regions from `region-localization-strategy.md`. - Countries/regions: use English-first regions from `region-localization-strategy.md`.
- Upload the same AAB. - Upload the same AAB.
- Release name: `1.0.0 closed test`. - Use release name `1.0.2 closed test`.
- Release notes: - Release notes:
```text ```text
First closed test release for Focus Seed. Closed test release for Focus Seed.
Please test the Today, Timer, Habits, Reflect, Settings, Export JSON, Import JSON, and Clear local data flows. 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. 4. Add testers.
@@ -58,11 +58,11 @@ Official references:
- Minimum passing target: 12 opted-in testers. - Minimum passing target: 12 opted-in testers.
- Tester emails must be Google accounts that can access Google Play. - 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. - 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. 5. Send tester instructions.
- Send the opt-in link from Play Console. - 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. - Ask each tester to install and open the app at least once.
6. Track 14 days. 6. Track 14 days.
@@ -74,14 +74,14 @@ Official references:
- Apply from the Play Console dashboard when eligible. - Apply from the Play Console dashboard when eligible.
- Answer questions using the notes in this file. - Answer questions using the notes in this file.
## 12 Tester Strategy For A Solo Developer ## Tester Strategy For A Solo Developer
Best compliant sources: Best compliant sources:
- Friends and family with Android phones and Google accounts. - Friends and family with Android phones and Google accounts.
- Former classmates, coworkers, or community contacts. - Former classmates, coworkers, or community contacts.
- Small indie developer groups where members exchange real testing help. - 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. - Existing users from your personal network.
Recommended count: Recommended count:
@@ -108,35 +108,7 @@ Tester success checklist:
- Tester tries at least one real flow. - Tester tries at least one real flow.
- Tester sends short feedback privately. - Tester sends short feedback privately.
## Tester Invitation Message - Chinese ## Tester Invitation Message
```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
```text ```text
I am running a closed Google Play test for a new Android app called Focus Seed. 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. 2. Join the test.
3. Install Focus Seed from Google Play. 3. Install Focus Seed from Google Play.
4. Try these flows: 4. Try these flows:
- Set a daily anchor on Today - Set today's focus on Today
- Start or complete a focus timer - Start or complete a focus timer
- Check off a habit - Check off a habit
- Save a short reflection - 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: 5. Send me quick feedback:
- Did install/open work? - Did install/open work?
- Was any screen confusing? - Was any screen confusing?
@@ -166,23 +140,20 @@ Thank you!
## Quick Feedback Questions ## Quick Feedback Questions
Send these as a message or simple form:
```text ```text
1. Your phone model: 1. Your phone model:
2. Android version, if known: 2. Android version, if known:
3. Did the app install from Google Play? Yes / No 3. Did the app install from Google Play? Yes / No
4. Did the app open successfully? Yes / No 4. Did the app open successfully? Yes / No
5. Which screen did you try? Today / Timer / Habits / Reflect / Settings 5. Which screen did you try? Today / Focus / Records / Me / habit manager
6. Any crash, freeze, lag, or layout issue? 6. Did default focus duration work as expected?
7. What felt confusing? 7. Any crash, freeze, lag, or layout issue?
8. One thing you would improve: 8. What felt confusing?
9. One thing you would improve:
``` ```
## Tester Tracking Table ## Tester Tracking Table
Use a spreadsheet or notes app:
```text ```text
Tester name | Google account email | Invited | Opted in | Installed | Opened app | Feedback received | Still opted in day 14 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: 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. - 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. - 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. - 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. - Readiness: no account, no server, no ads, no billing, no analytics SDK, no sensitive permissions, privacy policy and Data safety match the shipped app.
+2 -2
View File
@@ -8,8 +8,8 @@ This guidance assumes the submitted build is the current Focus Seed V1:
- No analytics SDK. - No analytics SDK.
- No billing SDK. - No billing SDK.
- No network permission. - No network permission.
- Data stored locally with `shared_preferences`. - Data stored locally with Drift/SQLite plus small local preferences.
- Export/import is user-controlled copy/paste JSON. - User can clear today's loop or delete local app records from settings.
## Suggested Data Safety Answers ## Suggested Data Safety Answers
+5 -5
View File
@@ -10,9 +10,9 @@ Private focus timer, habit tracker, and one-minute daily reflection.
## Full Description ## 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: Features:
@@ -22,7 +22,7 @@ Features:
- One-minute reflection log - One-minute reflection log
- Mood tags - Mood tags
- Local 7-day progress view - Local 7-day progress view
- JSON export and import - Default focus duration preference
- Clear local-data privacy controls - Clear local-data privacy controls
Focus Seed is designed for people who want a lightweight productivity tool without accounts, feeds, or complicated setup. 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 ## Release Notes
First release: Current release:
- Focus timer - Focus timer
- Habit tracker - Habit tracker
- Daily reflection - Daily reflection
- Local progress stats - Local progress stats
- Local JSON export/import - Default focus duration setting
- In-app privacy controls - In-app privacy controls
+2 -4
View File
@@ -71,17 +71,15 @@
<li>Habit names and completion dates</li> <li>Habit names and completion dates</li>
<li>Focus session completion time and duration</li> <li>Focus session completion time and duration</li>
<li>Reflection notes and mood tags</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> <li>Basic app state needed for the app to work</li>
</ul> </ul>
<h2>Data Sharing</h2> <h2>Data Sharing</h2>
<p>Focus Seed V1 does not share your app data with the developer or third parties.</p> <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> <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> <h2>Ads, Analytics, And Purchases</h2>
<p>Focus Seed V1 does not include advertising SDKs, analytics SDKs, or in-app purchase SDKs.</p> <p>Focus Seed V1 does not include advertising SDKs, analytics SDKs, or in-app purchase SDKs.</p>
+2 -5
View File
@@ -16,19 +16,16 @@ The app stores the following data locally on your device:
- Habit names and completion dates - Habit names and completion dates
- Focus session completion time and duration - Focus session completion time and duration
- Reflection notes and mood tags - Reflection notes and mood tags
- App preferences such as appearance and default focus duration
- Basic app state needed for the app to work - Basic app state needed for the app to work
## Data Sharing ## Data Sharing
Focus Seed V1 does not share your app data with the developer or third parties. 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 ## 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 ## Ads, Analytics, And Purchases
+6 -5
View File
@@ -1,6 +1,6 @@
# Focus Seed Product Brief # Focus Seed Product Brief
Status: V1 release candidate Status: historical release reference. Superseded for active product decisions by `docs/product-current-baseline.md`.
## Positioning ## 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. - Habit creation, rename, delete, and daily check-off.
- One-minute reflection with mood tag. - One-minute reflection with mood tag.
- 7-day progress pulse. - 7-day progress pulse.
- Local JSON export and import by copy/paste.
- In-app privacy explanation. - 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. - 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 ## V1 Exclusions
@@ -50,7 +51,7 @@ Defer EU/EEA, United Kingdom, Switzerland, South Korea, Japan, Brazil/LATAM, and
## Future Revenue Path ## Future Revenue Path
- V2/V3: optional low-interruption ads after retention validation. - 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. - Free core loop remains usable.
## App IDs ## App IDs
@@ -59,6 +60,6 @@ Defer EU/EEA, United Kingdom, Switzerland, South Korea, Japan, Brazil/LATAM, and
- Developer / brand: leavysong - Developer / brand: leavysong
- Support email: leavysong@gmail.com - Support email: leavysong@gmail.com
- Package/application ID: `com.dailyanchor.focusseed` - Package/application ID: `com.dailyanchor.focusseed`
- Version: `1.0.0+1` - Version: `1.0.2+3`
- Android min SDK: 26 - Android min SDK: 26
- Android target SDK: 35 - Android target SDK: 35
+3 -1
View File
@@ -1,6 +1,8 @@
# Focus Seed 1.0.1 Experience Brief # 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 ## 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

+2 -2
View File
@@ -18,7 +18,7 @@ Recommended phone screenshots:
2. Focus timer with progress ring. 2. Focus timer with progress ring.
3. Habits screen with completed and pending habits. 3. Habits screen with completed and pending habits.
4. Reflection screen with mood tags and text area. 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: Suggested captions:
@@ -26,7 +26,7 @@ Suggested captions:
- Start a calm focus session. - Start a calm focus session.
- Track tiny habits without an account. - Track tiny habits without an account.
- Finish with a one-minute reflection. - 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: Feature graphic direction:
+192 -27
View File
@@ -1,52 +1,217 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../core/ambience.dart';
class AppTheme { class AppTheme {
static const seed = Color(0xFF2E7D6B); static const ink = Color(0xFF17211C);
static const accent = Color(0xFFF4B740); static const forest = Color(0xFF08785B);
static const paper = Color(0xFFF7F8F5); 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() { static ThemeData light(DayPhase phase) {
final scheme = ColorScheme.fromSeed(seedColor: seed, surface: paper); final surface = switch (phase) {
return _base(scheme); DayPhase.dawn => const Color(0xFFF3F8FC),
} DayPhase.day => porcelain,
DayPhase.dusk => const Color(0xFFFBF6F4),
static ThemeData dark() { DayPhase.night => const Color(0xFFF2F5FA),
final scheme = ColorScheme.fromSeed( };
seedColor: seed, final scheme = ColorScheme.fromSeed(seedColor: ultramarine).copyWith(
brightness: Brightness.dark, 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) { static ThemeData dark(DayPhase phase) {
return ThemeData( final surface = switch (phase) {
useMaterial3: true, DayPhase.dawn => const Color(0xFF141C1B),
colorScheme: scheme, 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, scaffoldBackgroundColor: scheme.surface,
navigationBarTheme: NavigationBarThemeData(
indicatorColor: scheme.primaryContainer,
labelTextStyle: WidgetStateProperty.resolveWith(
(_) => const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
cardTheme: CardTheme( cardTheme: CardTheme(
elevation: 0, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
color: scheme.surfaceContainerHighest.withValues(alpha: 0.55), color: scheme.surfaceContainerLowest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shadowColor: ink.withValues(alpha: 0.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( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size(48, 48), minimumSize: const Size(48, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: const TextStyle(fontWeight: FontWeight.w700),
), ),
), ),
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size(48, 48), minimumSize: const Size(48, 48),
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)!,
); );
} }
} }
+176 -7
View File
@@ -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 '../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'; import 'app_theme.dart';
class FocusSeedApp extends StatelessWidget { @visibleForTesting
const FocusSeedApp({super.key}); Locale focusSeedDefaultLocale({bool isRelease = kReleaseMode}) {
return isRelease ? const Locale('en') : const Locale('zh');
}
class FocusSeedApp extends StatefulWidget {
const FocusSeedApp({
this.locale,
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Focus Seed', onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.light(), theme: AppTheme.light(_dayPhase),
darkTheme: AppTheme.dark(), darkTheme: AppTheme.dark(_dayPhase),
home: const FocusSeedHome(), themeMode: _themeMode,
themeAnimationDuration: const Duration(milliseconds: 240),
locale: _resolvedLocale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home:
!_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();
}
}
+39
View File
@@ -0,0 +1,39 @@
enum DayPhase { dawn, day, dusk, night }
DayPhase dayPhaseFor(DateTime dateTime) {
final hour = dateTime.hour;
if (hour >= 5 && hour < 10) {
return DayPhase.dawn;
}
if (hour >= 10 && hour < 17) {
return DayPhase.day;
}
if (hour >= 17 && hour < 21) {
return DayPhase.dusk;
}
return DayPhase.night;
}
DateTime nextDayPhaseBoundary(DateTime dateTime) {
final day = DateTime(dateTime.year, dateTime.month, dateTime.day);
if (dateTime.hour < 5) {
return day.add(const Duration(hours: 5));
}
if (dateTime.hour < 10) {
return day.add(const Duration(hours: 10));
}
if (dateTime.hour < 17) {
return day.add(const Duration(hours: 17));
}
if (dateTime.hour < 21) {
return day.add(const Duration(hours: 21));
}
return day.add(const Duration(days: 1, hours: 5));
}
int recommendedFocusMinutes(DateTime dateTime) {
final isWeekend =
dateTime.weekday == DateTime.saturday ||
dateTime.weekday == DateTime.sunday;
return isWeekend ? 15 : 25;
}
+1 -20
View File
@@ -11,25 +11,6 @@ String dateKey(DateTime date) {
'${local.day.toString().padLeft(2, '0')}'; '${local.day.toString().padLeft(2, '0')}';
} }
String readableToday() {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final now = DateTime.now();
return '${months[now.month - 1]} ${now.day}, ${now.year}';
}
List<String> lastSevenDateKeys() { List<String> lastSevenDateKeys() {
final now = DateTime.now(); final now = DateTime.now();
return List.generate( return List.generate(
@@ -41,7 +22,7 @@ List<String> lastSevenDateKeys() {
int minutesForDate(List<FocusSession> sessions, String key) { int minutesForDate(List<FocusSession> sessions, String key) {
return sessions return sessions
.where((session) => dateKey(session.completedAt) == key) .where((session) => dateKey(session.completedAt) == key)
.fold<int>(0, (total, session) => total + session.minutes); .fold<int>(0, (total, session) => total + session.actualMinutes);
} }
int currentStreak( int currentStreak(
+72
View File
@@ -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);
}
}
+409 -16
View File
@@ -1,33 +1,426 @@
import 'dart:convert'; import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:shared_preferences/shared_preferences.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/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 { class FocusSeedStore {
static const _storageKey = 'focus_seed_state_v1'; FocusSeedStore({FocusSeedDatabase? database})
: _database = database ?? FocusSeedDatabase();
Future<AppState> load() async { static const _legacyStorageKey = 'focus_seed_state_v1';
final prefs = await SharedPreferences.getInstance(); static const _legacyMigrationKey = 'focus_seed_state_v1_migrated_to_sqlite';
final raw = prefs.getString(_storageKey); static const _defaultFocusMinutesKey = 'default_focus_minutes';
if (raw == null || raw.isEmpty) {
return AppState.starter(); final FocusSeedDatabase _database;
}
try { Future<AppState> load({List<String>? defaultHabitTitles}) async {
return AppState.fromJson(jsonDecode(raw) as Map<String, dynamic>); await _migrateLegacyJsonIfNeeded();
} on Object { final snapshot = await _readSnapshot();
return AppState.starter(); if (!snapshot.isFreshStart) {
return snapshot;
} }
final starter = AppState.starter(defaultHabitTitles: defaultHabitTitles);
await save(starter);
return starter;
} }
Future<void> save(AppState state) async { Future<void> save(AppState state) async {
final prefs = await SharedPreferences.getInstance(); await _database.transaction(() async {
await prefs.setString(_storageKey, prettyJson(state.toJson())); 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 { Future<void> clear() async {
final prefs = await SharedPreferences.getInstance(); await _database.clearProductData();
await prefs.remove(_storageKey); 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',
);
} }
} }
+212
View File
@@ -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)),
),
),
],
),
);
}
}
-147
View File
@@ -1,147 +0,0 @@
import 'package:flutter/material.dart';
import '../../app/app_theme.dart';
import '../../core/date_keys.dart';
import '../../models/app_state.dart';
import '../../models/habit.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/empty_state.dart';
import '../../widgets/habit_row.dart';
import '../../widgets/inline_editable_text.dart';
import '../../widgets/metric_tile.dart';
import '../../widgets/section_label.dart';
import '../../widgets/seed_card.dart';
import '../../widgets/weekly_progress.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({
required this.state,
required this.onAnchorChanged,
required this.onOpenTimer,
required this.onToggleHabit,
required this.onAddHabit,
super.key,
});
final AppState state;
final ValueChanged<String> onAnchorChanged;
final VoidCallback onOpenTimer;
final ValueChanged<Habit> onToggleHabit;
final ValueChanged<String> onAddHabit;
@override
Widget build(BuildContext context) {
final today = todayKey();
final completed =
state.habits
.where((habit) => habit.completedDates.contains(today))
.length;
final focusMinutes = minutesForDate(state.sessions, today);
final streak = currentStreak(
state.habits,
state.sessions,
state.reflections,
);
return AppScaffold(
title: 'Focus Seed',
subtitle: readableToday(),
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
children: [
SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel(
icon: Icons.flag_outlined,
text: 'Today anchor',
),
const SizedBox(height: 12),
InlineEditableText(
value: state.anchor,
emptyText: 'Choose one important task',
onSubmitted: onAnchorChanged,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onOpenTimer,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Start focus'),
),
],
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: MetricTile(
label: 'Focus',
value: '$focusMinutes',
suffix: 'min',
icon: Icons.timer_outlined,
),
),
const SizedBox(width: 10),
Expanded(
child: MetricTile(
label: 'Habits',
value: '$completed/${state.habits.length}',
suffix: '',
icon: Icons.check_circle_outline,
),
),
const SizedBox(width: 10),
Expanded(
child: MetricTile(
label: 'Streak',
value: '$streak',
suffix: 'days',
icon: Icons.local_fire_department_outlined,
accent: AppTheme.accent,
),
),
],
),
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Today habits',
style: Theme.of(context).textTheme.titleMedium,
),
IconButton.filledTonal(
tooltip: 'Add habit',
onPressed: () => showHabitDialog(context, onAddHabit),
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 8),
if (state.habits.isEmpty)
EmptyState(
icon: Icons.checklist_rtl,
title: 'Add one tiny habit',
action: 'Add habit',
onAction: () => showHabitDialog(context, onAddHabit),
)
else
...state.habits
.take(5)
.map(
(habit) => HabitRow(
habit: habit,
dateKey: today,
onToggle: () => onToggleHabit(habit),
),
),
const SizedBox(height: 18),
WeeklyProgress(state: state),
],
),
);
}
}
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),
),
),
],
),
);
}
}
+373 -209
View File
@@ -1,269 +1,433 @@
import 'dart:convert'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../core/date_keys.dart'; import '../../app/app_theme.dart';
import '../../application/focus_seed_controller.dart';
import '../../application/focus_timer_controller.dart';
import '../../data/focus_seed_store.dart'; import '../../data/focus_seed_store.dart';
import '../../models/app_state.dart'; import '../../data/focus_timer_session_store.dart';
import '../../models/focus_session.dart'; import '../../l10n/l10n.dart';
import '../../models/habit.dart'; import '../../models/appearance_preference.dart';
import '../../models/mood.dart'; import '../../models/language_preference.dart';
import '../../models/reflection_entry.dart';
import '../../services/analytics_provider.dart';
import '../../widgets/dialogs.dart';
import '../../widgets/loading_view.dart'; import '../../widgets/loading_view.dart';
import '../habits/habits_screen.dart'; import '../../widgets/tactile_canvas.dart';
import '../home/home_screen.dart'; import '../focus/focus_screen.dart';
import '../reflection/reflection_screen.dart'; import '../me/me_screen.dart';
import '../settings/settings_screen.dart'; import '../onboarding/landing_setup_screen.dart';
import '../timer/timer_screen.dart'; import '../records/records_screen.dart';
import '../today/today_screen.dart';
class FocusSeedHome extends StatefulWidget { class FocusSeedHome extends StatefulWidget {
const FocusSeedHome({super.key}); const FocusSeedHome({
required this.appearance,
required this.onAppearanceChanged,
required this.language,
required this.showLanguageTools,
required this.onLanguageChanged,
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 @override
State<FocusSeedHome> createState() => _FocusSeedHomeState(); State<FocusSeedHome> createState() => _FocusSeedHomeState();
} }
class _FocusSeedHomeState extends State<FocusSeedHome> { class _FocusSeedHomeState extends State<FocusSeedHome> {
final _store = FocusSeedStore(); FocusSeedController? _controller;
final _analytics = const NoopAnalyticsProvider(); FocusTimerController? _timerController;
AppState? _state;
int _tabIndex = 0; int _tabIndex = 0;
late bool _landingSetupVisible;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_load(); _landingSetupVisible = widget.showLandingSetupInitially;
} }
Future<void> _load() async { @override
final state = await _store.load(); 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) { if (!mounted) {
return; return;
} }
setState(() => _state = state); if (restored) {
setState(() => _tabIndex = 1);
return;
} }
timerController.selectMinutes(defaultMinutes);
Future<void> _setStateAndSave(AppState state) async {
setState(() => _state = state);
await _store.save(state);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = _state; final controller = _controller;
final timerController = _timerController;
if (controller == null || timerController == null) {
return const Scaffold(body: SafeArea(child: LoadingView()));
}
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
controller.updateDefaultHabitTitles(
localizedStarterHabits(context.l10n),
);
final state = controller.state;
if (controller.isLoading) {
return const Scaffold(body: SafeArea(child: LoadingView()));
}
if (state == null) {
final l10n = context.l10n;
return Scaffold( return Scaffold(
body: AnimatedSwitcher( body: SafeArea(
duration: const Duration(milliseconds: 180), child: Center(
child: 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: child:
state == null _landingSetupVisible
? const LoadingView() ? LandingSetupScreen(
controller: controller,
timerController: timerController,
onDone: completeLandingSetup,
onStartFocus:
() => completeLandingSetup(openFocus: true),
)
: SafeArea( : SafeArea(
child: IndexedStack( child: IndexedStack(
index: _tabIndex, index: _tabIndex,
children: [ children: [
HomeScreen( TodayScreen(
state: state, controller: controller,
onAnchorChanged: timerController: timerController,
(anchor) => _setStateAndSave( onOpenSettings: openMe,
state.copyWith(anchor: anchor.trim()), onOpenFocus:
() => setState(() => _tabIndex = 1),
), ),
onOpenTimer: () => setState(() => _tabIndex = 1), FocusScreen(
onToggleHabit: _toggleHabit, controller: controller,
onAddHabit: _addHabit, timerController: timerController,
onBackToday:
() => setState(() => _tabIndex = 0),
), ),
TimerScreen( RecordsScreen(
onComplete: _completeFocusSession, controller: controller,
onReflect: () => setState(() => _tabIndex = 3), onStartToday:
() => setState(() => _tabIndex = 0),
), ),
HabitsScreen( MeScreen(
state: state, controller: controller,
onAddHabit: _addHabit, appearance: widget.appearance,
onRenameHabit: _renameHabit, onAppearanceChanged:
onDeleteHabit: _deleteHabit, widget.onAppearanceChanged,
onToggleHabit: _toggleHabit, language: widget.language,
), showLanguageTools: widget.showLanguageTools,
ReflectionScreen(state: state, onSave: _saveReflection), onLanguageChanged: widget.onLanguageChanged,
SettingsScreen( onDefaultFocusMinutesChanged: (minutes) {
state: state, if (!timerController.isRunning &&
onImport: _importState, !timerController.isPaused) {
onClear: _clearData, timerController.selectMinutes(minutes);
}
},
embedded: true,
), ),
], ],
), ),
), ),
), ),
bottomNavigationBar: bottomNavigationBar:
state == null _landingSetupVisible || hideBottomNavigation
? null ? null
: NavigationBar( : _FocusSeedNavigationBar(
selectedIndex: _tabIndex, selectedIndex: _tabIndex,
onDestinationSelected: (index) { onDestinationSelected: (index) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
setState(() => _tabIndex = index); 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( _FocusSeedNavigationDestination(
icon: Icon(Icons.edit_note_outlined), key: 'focus',
selectedIcon: Icon(Icons.edit_note), icon: Icons.timer_outlined,
label: 'Reflect', 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; class _FocusSeedNavigationDestination {
if (state == null) { const _FocusSeedNavigationDestination({
return; required this.key,
} required this.icon,
final today = todayKey(); required this.label,
final completedDates = Set<String>.from(habit.completedDates); });
if (completedDates.contains(today)) {
completedDates.remove(today); final String key;
} else { final IconData icon;
completedDates.add(today); final String label;
await HapticFeedback.lightImpact(); }
}
final habits = class _FocusSeedNavigationItem extends StatelessWidget {
state.habits const _FocusSeedNavigationItem({
.map( required this.destination,
(item) => required this.selected,
item.id == habit.id required this.onTap,
? habit.copyWith(completedDates: completedDates) });
: item,
) final _FocusSeedNavigationDestination destination;
.toList(); final bool selected;
_analytics.track('habit_toggled'); final VoidCallback onTap;
await _setStateAndSave(state.copyWith(habits: habits));
} @override
Widget build(BuildContext context) {
Future<void> _addHabit(String title) async { final theme = Theme.of(context);
final state = _state; final visuals = FocusSeedVisuals.of(context);
final clean = title.trim(); final isDark = theme.brightness == Brightness.dark;
if (state == null || clean.isEmpty) { final selectedColor = visuals.focus;
return; final inactiveColor =
} isDark ? theme.colorScheme.onSurfaceVariant : const Color(0xFF4B5563);
final habit = Habit( final foregroundColor = selected ? selectedColor : inactiveColor;
id: 'habit-${DateTime.now().microsecondsSinceEpoch}', return Semantics(
title: clean, button: true,
createdAt: DateTime.now(), selected: selected,
completedDates: {}, label: destination.label,
); child: InkWell(
_analytics.track('habit_created'); key: ValueKey('focus-seed-nav-${destination.key}'),
await _setStateAndSave(state.copyWith(habits: [...state.habits, habit])); onTap: onTap,
} child: SizedBox.expand(
child: Stack(
Future<void> _renameHabit(Habit habit, String title) async { children: [
final state = _state; Align(
final clean = title.trim(); alignment: Alignment.topCenter,
if (state == null || clean.isEmpty) { child: AnimatedContainer(
return; duration: const Duration(milliseconds: 180),
} curve: Curves.easeOutCubic,
final habits = width: selected ? 34 : 0,
state.habits height: 3,
.map( decoration: BoxDecoration(
(item) => color: selectedColor,
item.id == habit.id ? item.copyWith(title: clean) : item, borderRadius: const BorderRadius.only(
) bottomLeft: Radius.circular(3),
.toList(); bottomRight: Radius.circular(3),
await _setStateAndSave(state.copyWith(habits: habits)); ),
} ),
),
Future<void> _deleteHabit(Habit habit) async { ),
final state = _state; Center(
if (state == null) { child: Column(
return; mainAxisSize: MainAxisSize.min,
} children: [
final habits = state.habits.where((item) => item.id != habit.id).toList(); Icon(destination.icon, size: 23, color: foregroundColor),
await _setStateAndSave(state.copyWith(habits: habits)); const SizedBox(height: 5),
} AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 180),
Future<void> _completeFocusSession(int minutes) async { curve: Curves.easeOutCubic,
final state = _state; style: theme.textTheme.labelMedium!.copyWith(
if (state == null) { color: foregroundColor,
return; fontSize: 12,
} fontWeight:
final session = FocusSession( selected ? FontWeight.w800 : FontWeight.w600,
id: 'session-${DateTime.now().microsecondsSinceEpoch}', letterSpacing: 0,
minutes: minutes, height: 1.1,
completedAt: DateTime.now(), ),
); child: Text(
_analytics.track('focus_completed', {'minutes': minutes}); destination.label,
await _setStateAndSave( maxLines: 1,
state.copyWith(sessions: [...state.sessions, session]), 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, String? _messageForError(AppLocalizations l10n, FocusSeedError? error) {
mood: mood, return switch (error) {
createdAt: DateTime.now(), FocusSeedError.openFailed => l10n.openFailed,
); FocusSeedError.saveFailed => l10n.saveFailed,
final reflections = [ null => null,
...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');
}
}
} }
-212
View File
@@ -1,212 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/date_keys.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/seed_card.dart';
class TimerScreen extends StatefulWidget {
const TimerScreen({
required this.onComplete,
required this.onReflect,
super.key,
});
final ValueChanged<int> onComplete;
final VoidCallback onReflect;
@override
State<TimerScreen> createState() => _TimerScreenState();
}
class _TimerScreenState extends State<TimerScreen> {
static const _presets = [15, 25, 45];
Timer? _timer;
int _selectedMinutes = 25;
int _remainingSeconds = 25 * 60;
bool _running = false;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final totalSeconds = _selectedMinutes * 60;
final progress = 1 - (_remainingSeconds / totalSeconds);
return AppScaffold(
title: 'Focus timer',
subtitle: 'No ads or distractions here.',
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
children: [
SeedCard(
child: Column(
children: [
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
children:
_presets
.map(
(minutes) => ChoiceChip(
label: Text('$minutes min'),
selected: _selectedMinutes == minutes,
onSelected:
_running
? null
: (_) => setState(() {
_selectedMinutes = minutes;
_remainingSeconds = minutes * 60;
}),
),
)
.toList(),
),
const SizedBox(height: 30),
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: progress.clamp(0, 1)),
duration: const Duration(milliseconds: 240),
builder:
(context, value, child) => SizedBox.square(
dimension: 220,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: value,
strokeWidth: 14,
strokeCap: StrokeCap.round,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
child!,
],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formatSeconds(_remainingSeconds),
style: Theme.of(context).textTheme.displayMedium
?.copyWith(fontWeight: FontWeight.w800),
),
Text(
_running ? 'Focus in progress' : 'Ready when you are',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 28),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _running ? _pause : _start,
icon: Icon(_running ? Icons.pause : Icons.play_arrow),
label: Text(_running ? 'Pause' : 'Start'),
),
),
const SizedBox(width: 10),
IconButton.filledTonal(
tooltip: 'Reset timer',
onPressed: _reset,
icon: const Icon(Icons.restart_alt),
),
],
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _completeNow,
icon: const Icon(Icons.check),
label: const Text('Mark session complete'),
),
],
),
),
const SizedBox(height: 16),
SeedCard(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.edit_note_outlined),
title: const Text('Finish with a one-minute reflection'),
subtitle: const Text('Capture what worked while it is fresh.'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: widget.onReflect,
),
),
],
),
);
}
void _start() {
if (_running) {
return;
}
HapticFeedback.selectionClick();
setState(() => _running = true);
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_remainingSeconds <= 1) {
_completeNow();
return;
}
setState(() => _remainingSeconds -= 1);
});
}
void _pause() {
_timer?.cancel();
setState(() => _running = false);
}
void _reset() {
_timer?.cancel();
setState(() {
_running = false;
_remainingSeconds = _selectedMinutes * 60;
});
}
void _completeNow() {
_timer?.cancel();
final completedMinutes = _selectedMinutes;
setState(() {
_running = false;
_remainingSeconds = _selectedMinutes * 60;
});
HapticFeedback.mediumImpact();
widget.onComplete(completedMinutes);
showDialog<void>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Session saved'),
content: const Text(
'Take one minute to write what helped you focus.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Later'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
widget.onReflect();
},
child: const Text('Reflect'),
),
],
),
);
}
}
+742
View File
@@ -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,
),
],
),
);
}
}
+349
View File
@@ -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"
}
+349
View File
@@ -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 => '写一句复盘';
}
+54
View File
@@ -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,
];
}
+164 -7
View File
@@ -1,50 +1,166 @@
import '../core/focus_durations.dart';
import 'daily_action.dart';
import 'focus_session.dart'; import 'focus_session.dart';
import 'habit.dart'; import 'habit.dart';
import 'progress_note.dart';
import 'reflection_entry.dart'; import 'reflection_entry.dart';
class AppState { class AppState {
const AppState({ const AppState({
required this.anchor, required this.anchor,
required this.anchorDateKey,
required this.habits, required this.habits,
required this.sessions, required this.sessions,
required this.reflections, required this.reflections,
this.actions = const [],
this.selectedActionId = '',
this.progressNotes = const [],
this.recentLabels = const [],
this.defaultFocusMinutes = defaultFocusDurationMinutes,
}); });
final String anchor; final String anchor;
final String anchorDateKey;
final List<Habit> habits; final List<Habit> habits;
final List<FocusSession> sessions; final List<FocusSession> sessions;
final List<ReflectionEntry> reflections; final List<ReflectionEntry> reflections;
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({ AppState copyWith({
String? anchor, String? anchor,
String? anchorDateKey,
List<Habit>? habits, List<Habit>? habits,
List<FocusSession>? sessions, List<FocusSession>? sessions,
List<ReflectionEntry>? reflections, List<ReflectionEntry>? reflections,
List<DailyAction>? actions,
String? selectedActionId,
List<ProgressNote>? progressNotes,
List<String>? recentLabels,
int? defaultFocusMinutes,
}) { }) {
return AppState( return AppState(
anchor: anchor ?? this.anchor, anchor: anchor ?? this.anchor,
anchorDateKey: anchorDateKey ?? this.anchorDateKey,
habits: habits ?? this.habits, habits: habits ?? this.habits,
sessions: sessions ?? this.sessions, sessions: sessions ?? this.sessions,
reflections: reflections ?? this.reflections, reflections: reflections ?? this.reflections,
actions: actions ?? this.actions,
selectedActionId: selectedActionId ?? this.selectedActionId,
progressNotes: progressNotes ?? this.progressNotes,
recentLabels: recentLabels ?? this.recentLabels,
defaultFocusMinutes: defaultFocusMinutes ?? this.defaultFocusMinutes,
); );
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'version': 1, 'version': 2,
'anchor': anchor, 'anchor': anchor,
'anchorDateKey': anchorDateKey,
'habits': habits.map((habit) => habit.toJson()).toList(), 'habits': habits.map((habit) => habit.toJson()).toList(),
'sessions': sessions.map((session) => session.toJson()).toList(), 'sessions': sessions.map((session) => session.toJson()).toList(),
'reflections': 'reflections':
reflections.map((reflection) => reflection.toJson()).toList(), 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) { static AppState fromJson(Map<String, dynamic> json) {
final anchor = (json['anchor'] as String?) ?? '';
final defaultMinutes = _validDefaultFocusMinutes(
json['defaultFocusMinutes'] as int?,
);
return AppState( return AppState(
anchor: (json['anchor'] as String?) ?? '', anchor: anchor,
anchorDateKey:
(json['anchorDateKey'] as String?) ??
(anchor.isEmpty ? '' : _currentDateKey()),
habits: _jsonObjects(json['habits']).map(Habit.fromJson).toList(), habits: _jsonObjects(json['habits']).map(Habit.fromJson).toList(),
sessions: sessions:
_jsonObjects(json['sessions']).map(FocusSession.fromJson).toList(), _jsonObjects(json['sessions']).map(FocusSession.fromJson).toList(),
@@ -52,6 +168,14 @@ class AppState {
_jsonObjects( _jsonObjects(
json['reflections'], json['reflections'],
).map(ReflectionEntry.fromJson).toList(), ).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>>(); 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 now = DateTime.now();
final titles =
defaultHabitTitles?.length == 3
? defaultHabitTitles!
: const [
'Plan one clear task',
'Focus for 10 minutes',
'Write one-line reflection',
];
return AppState( return AppState(
anchor: '', anchor: '',
anchorDateKey: _currentDateKey(now),
habits: [ habits: [
Habit( Habit(
id: 'habit-${now.microsecondsSinceEpoch}-1', id: 'habit-${now.microsecondsSinceEpoch}-1',
title: 'Plan one clear task', title: titles[0],
createdAt: now, createdAt: now,
completedDates: {}, completedDates: {},
), ),
Habit( Habit(
id: 'habit-${now.microsecondsSinceEpoch}-2', id: 'habit-${now.microsecondsSinceEpoch}-2',
title: 'Focus for 25 minutes', title: titles[1],
createdAt: now, createdAt: now,
completedDates: {}, completedDates: {},
), ),
Habit( Habit(
id: 'habit-${now.microsecondsSinceEpoch}-3', id: 'habit-${now.microsecondsSinceEpoch}-3',
title: 'Write a one-line reflection', title: titles[2],
createdAt: now, createdAt: now,
completedDates: {}, completedDates: {},
), ),
@@ -90,4 +233,18 @@ class AppState {
reflections: const [], 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,
);
}
+26 -2
View File
@@ -3,25 +3,49 @@ class FocusSession {
required this.id, required this.id,
required this.minutes, required this.minutes,
required this.completedAt, required this.completedAt,
}); this.intention = '',
this.actionId,
String? actionTitleSnapshot,
int? actualSeconds,
}) : actualSeconds = actualSeconds ?? minutes * 60,
actionTitleSnapshot = actionTitleSnapshot ?? intention;
final String id; final String id;
final int minutes; final int minutes;
final int actualSeconds;
final DateTime completedAt; 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() { Map<String, dynamic> toJson() {
return { return {
'id': id, 'id': id,
'minutes': minutes, 'minutes': minutes,
'actualSeconds': actualSeconds,
'completedAt': completedAt.toIso8601String(), 'completedAt': completedAt.toIso8601String(),
'intention': intention,
'actionId': actionId,
'actionTitleSnapshot': actionTitleSnapshot,
}; };
} }
static FocusSession fromJson(Map<String, dynamic> json) { static FocusSession fromJson(Map<String, dynamic> json) {
final minutes = json['minutes'] as int;
return FocusSession( return FocusSession(
id: json['id'] as String, 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), completedAt: DateTime.parse(json['completedAt'] as String),
intention: (json['intention'] as String?) ?? '',
actionId: json['actionId'] as String?,
actionTitleSnapshot: json['actionTitleSnapshot'] as String?,
); );
} }
} }
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
enum LanguagePreference { defaultLocale, english, chinese }
LanguagePreference languagePreferenceFromName(String? name) {
return LanguagePreference.values.firstWhere(
(preference) => preference.name == name,
orElse: () => LanguagePreference.defaultLocale,
);
}
extension LanguagePreferenceLocale on LanguagePreference {
Locale? get locale => switch (this) {
LanguagePreference.defaultLocale => null,
LanguagePreference.english => const Locale('en'),
LanguagePreference.chinese => const Locale('zh'),
};
}
+5 -6
View File
@@ -1,13 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
enum Mood { enum Mood {
calm('Calm', Icons.spa_outlined), calm(Icons.spa_outlined),
clear('Clear', Icons.wb_sunny_outlined), clear(Icons.wb_sunny_outlined),
proud('Proud', Icons.emoji_events_outlined), proud(Icons.emoji_events_outlined),
tired('Tired', Icons.bedtime_outlined); tired(Icons.bedtime_outlined);
const Mood(this.label, this.icon); const Mood(this.icon);
final String label;
final IconData icon; final IconData icon;
} }
+49
View File
@@ -0,0 +1,49 @@
import '../core/date_keys.dart';
import 'app_state.dart';
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 {}
}
-52
View File
@@ -1,52 +0,0 @@
import 'package:flutter/material.dart';
class AppScaffold extends StatelessWidget {
const AppScaffold({
required this.title,
required this.subtitle,
required this.child,
this.action,
super.key,
});
final String title;
final String subtitle;
final Widget child;
final Widget? action;
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
if (action != null) action!,
],
),
),
Expanded(child: child),
],
);
}
}
+453 -156
View File
@@ -1,210 +1,398 @@
import 'package:flutter/material.dart'; import 'dart:async';
import 'package:flutter/services.dart';
import '../core/json_utils.dart'; import 'package:flutter/material.dart';
import '../models/app_state.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( Future<void> showHabitDialog(
BuildContext context, BuildContext context,
ValueChanged<String> onSubmit, { Future<bool> Function(String value) onSubmit, {
String initialValue = '', String initialValue = '',
}) async { }) async {
final controller = TextEditingController(text: initialValue); final l10n = context.l10n;
await showDialog<void>( await showTextEntrySheet(
context: context, 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: builder:
(context) => AlertDialog( (context) => _TextEntrySheet(
title: Text(initialValue.isEmpty ? 'Add habit' : 'Rename habit'), title: title,
content: TextField( hintText: hintText,
controller: controller, 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, autofocus: true,
maxLength: widget.maxLength,
minLines: widget.minLines,
maxLines: widget.maxLines,
textCapitalization: TextCapitalization.sentences, textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration( textInputAction:
border: OutlineInputBorder(), widget.maxLines == 1
hintText: 'Example: Read 10 pages', ? TextInputAction.done
), : TextInputAction.newline,
onSubmitted: (value) { onChanged: (_) {
onSubmit(value); if (_errorText == null) {
Navigator.pop(context); 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), onPressed: () => Navigator.pop(context),
child: const Text('Cancel'), child: Text(l10n.cancel),
), ),
FilledButton( ),
onPressed: () { const SizedBox(width: 10),
onSubmit(controller.text); Expanded(
Navigator.pop(context); flex: 2,
}, child: FilledButton(
child: const Text('Save'), 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( Future<void> confirmDeleteHabit(
BuildContext context, BuildContext context,
VoidCallback onConfirm, VoidCallback onConfirm,
) async { ) async {
await showDialog<void>( await showConfirmSheet(
context: context, context: context,
builder: title: context.l10n.deleteHabitTitle,
(context) => AlertDialog( body: context.l10n.deleteHabitBody,
title: const Text('Delete habit?'), confirmLabel: context.l10n.delete,
content: const Text( icon: Icons.delete_outline_rounded,
'Past completion records for this habit will be removed.', accentBuilder: (context) => Theme.of(context).colorScheme.error,
), onConfirm: onConfirm,
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: const Text('Delete'),
),
],
),
); );
} }
Future<void> showExportDialog(BuildContext context, AppState state) async { Future<void> confirmClearToday(
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(
BuildContext context, BuildContext context,
ValueChanged<String> onImport, VoidCallback onConfirm,
) async { ) async {
final controller = TextEditingController(); await showConfirmSheet(
await showDialog<void>(
context: context, context: context,
builder: title: context.l10n.clearTodayDataTitle,
(context) => AlertDialog( body: context.l10n.clearTodayDataBody,
title: const Text('Import JSON'), confirmLabel: context.l10n.clearToday,
content: TextField( icon: Icons.refresh_rounded,
controller: controller, accentBuilder: (context) => FocusSeedVisuals.of(context).warning,
minLines: 8, onConfirm: onConfirm,
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'),
),
],
),
); );
controller.dispose();
} }
Future<void> confirmClearData( Future<void> confirmClearData(
BuildContext context, BuildContext context,
VoidCallback onConfirm, VoidCallback onConfirm,
) async { ) async {
await showDialog<void>( await showConfirmSheet(
context: context, 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: builder:
(context) => AlertDialog( (context) => _ConfirmSheet(
title: const Text('Clear local data?'), title: title,
content: const Text( body: body,
'This resets habits, focus sessions, and reflections on this device.', 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( child: Text(
onPressed: () { body,
onConfirm(); style: theme.textTheme.bodyMedium?.copyWith(
Navigator.pop(context); color: scheme.onSurface,
}, height: 1.42,
child: const Text('Clear'), fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 14),
_ConfirmActions(
accent: accent,
confirmLabel: confirmLabel,
onConfirm: onConfirm,
), ),
], ],
), ),
); );
}
} }
void showPrivacySheet(BuildContext context) { void showPrivacySheet(BuildContext context) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
showDragHandle: true, isScrollControlled: true,
useSafeArea: true,
builder: builder:
(context) => Padding( (context) => AppBottomSheetScaffold(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), title: context.l10n.privacyTitle,
child: ListView( subtitle: context.l10n.privacySubtitle,
shrinkWrap: true, icon: Icons.lock_outline_rounded,
children: const [ accent: FocusSeedVisuals.of(context).privacy,
Text( child: Column(
'Privacy', crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800), children: [
_PrivacyPoint(
icon: Icons.phone_android_rounded,
text: context.l10n.privacyBodyOne,
), ),
SizedBox(height: 12), const SizedBox(height: 12),
Text( _PrivacyPoint(
'Focus Seed stores your anchor task, habits, focus sessions, ' icon: Icons.cleaning_services_rounded,
'reflections, and settings locally on this device. V1 does not ' text: context.l10n.privacyBodyTwo,
'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.',
), ),
], ],
), ),
@@ -212,6 +400,115 @@ void showPrivacySheet(BuildContext context) {
); );
} }
void showSnack(BuildContext context, String message) { class _PrivacyPoint extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); 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),
],
);
},
);
}
} }
-37
View File
@@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
import 'seed_card.dart';
class EmptyState extends StatelessWidget {
const EmptyState({
required this.icon,
required this.title,
required this.action,
required this.onAction,
super.key,
});
final IconData icon;
final String title;
final String action;
final VoidCallback onAction;
@override
Widget build(BuildContext context) {
return SeedCard(
child: Column(
children: [
Icon(icon, size: 42, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 12),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onAction,
icon: const Icon(Icons.add),
label: Text(action),
),
],
),
);
}
}
-77
View File
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import '../models/habit.dart';
import 'seed_card.dart';
class HabitRow extends StatelessWidget {
const HabitRow({
required this.habit,
required this.dateKey,
required this.onToggle,
this.onEdit,
this.onDelete,
super.key,
});
final Habit habit;
final String dateKey;
final VoidCallback onToggle;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final done = habit.completedDates.contains(dateKey);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.only(bottom: 10),
child: SeedCard(
child: Row(
children: [
IconButton.filledTonal(
tooltip: done ? 'Mark not done' : 'Mark done',
onPressed: onToggle,
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
child: Icon(
done ? Icons.check : Icons.circle_outlined,
key: ValueKey(done),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
habit.title,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
decoration: done ? TextDecoration.lineThrough : null,
color:
done
? Theme.of(context).colorScheme.onSurfaceVariant
: null,
),
),
),
if (onEdit != null)
PopupMenuButton<String>(
tooltip: 'Habit options',
onSelected: (value) {
if (value == 'edit') {
onEdit?.call();
}
if (value == 'delete') {
onDelete?.call();
}
},
itemBuilder:
(context) => const [
PopupMenuItem(value: 'edit', child: Text('Rename')),
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
),
);
}
}
-60
View File
@@ -1,60 +0,0 @@
import 'package:flutter/material.dart';
class InlineEditableText extends StatefulWidget {
const InlineEditableText({
required this.value,
required this.emptyText,
required this.onSubmitted,
super.key,
});
final String value;
final String emptyText;
final ValueChanged<String> onSubmitted;
@override
State<InlineEditableText> createState() => _InlineEditableTextState();
}
class _InlineEditableTextState extends State<InlineEditableText> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(covariant InlineEditableText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value && _controller.text != widget.value) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
textCapitalization: TextCapitalization.sentences,
onSubmitted: widget.onSubmitted,
onTapOutside: (_) => widget.onSubmitted(_controller.text),
decoration: InputDecoration(
hintText: widget.emptyText,
suffixIcon: IconButton(
tooltip: 'Save anchor',
onPressed: () => widget.onSubmitted(_controller.text),
icon: const Icon(Icons.check),
),
border: const OutlineInputBorder(),
),
);
}
}
+135 -1
View File
@@ -1,10 +1,144 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app/app_theme.dart';
import '../l10n/l10n.dart';
import 'app_motion.dart';
class LoadingView extends StatelessWidget { class LoadingView extends StatelessWidget {
const LoadingView({super.key}); const LoadingView({super.key});
@override @override
Widget build(BuildContext context) { 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;
} }
} }
-61
View File
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'seed_card.dart';
class MetricTile extends StatelessWidget {
const MetricTile({
required this.label,
required this.value,
required this.suffix,
required this.icon,
this.accent,
super.key,
});
final String label;
final String value;
final String suffix;
final IconData icon;
final Color? accent;
@override
Widget build(BuildContext context) {
final color = accent ?? Theme.of(context).colorScheme.primary;
return SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: color),
const SizedBox(height: 12),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
if (suffix.isNotEmpty) ...[
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
suffix,
style: Theme.of(context).textTheme.labelMedium,
),
),
],
],
),
),
const SizedBox(height: 4),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
),
);
}
}
-25
View File
@@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
class SectionLabel extends StatelessWidget {
const SectionLabel({required this.icon, required this.text, super.key});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(
text,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
],
);
}
}
-16
View File
@@ -1,16 +0,0 @@
import 'package:flutter/material.dart';
class SeedCard extends StatelessWidget {
const SeedCard({required this.child, this.margin, super.key});
final Widget child;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
return Card(
margin: margin,
child: Padding(padding: const EdgeInsets.all(16), child: child),
);
}
}
+94 -11
View File
@@ -1,31 +1,114 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SettingsTile extends StatelessWidget { import 'workbench_ui.dart';
const SettingsTile({
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.icon,
required this.title, required this.title,
required this.subtitle, required this.subtitle,
required this.color,
required this.onTap, required this.onTap,
this.destructive = false,
super.key, super.key,
}); });
final IconData icon; final IconData icon;
final String title; final String title;
final String subtitle; final String subtitle;
final Color color;
final VoidCallback onTap; final VoidCallback onTap;
final bool destructive;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = destructive ? Theme.of(context).colorScheme.error : null; final theme = Theme.of(context);
return ListTile( final scheme = theme.colorScheme;
contentPadding: EdgeInsets.zero, return Material(
leading: Icon(icon, color: color), color: Colors.transparent,
title: Text(title, style: TextStyle(color: color)), child: InkWell(
subtitle: Text(subtitle),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: onTap, 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),
],
),
),
),
); );
} }
} }
+96
View File
@@ -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),
),
),
);
}
}
-77
View File
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import '../core/date_keys.dart';
import '../models/app_state.dart';
import 'section_label.dart';
import 'seed_card.dart';
class WeeklyProgress extends StatelessWidget {
const WeeklyProgress({required this.state, super.key});
final AppState state;
@override
Widget build(BuildContext context) {
final days = lastSevenDateKeys();
return SeedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SectionLabel(
icon: Icons.insights_outlined,
text: '7-day pulse',
),
const SizedBox(height: 14),
Row(
children:
days.map((day) {
final minutes = minutesForDate(state.sessions, day);
final habits =
state.habits
.where((habit) => habit.completedDates.contains(day))
.length;
final hasReflection = state.reflections.any(
(reflection) => reflection.dateKey == day,
);
final score =
(minutes > 0 ? 1 : 0) +
(habits > 0 ? 1 : 0) +
(hasReflection ? 1 : 0);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Column(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: 48 + (score * 8),
decoration: BoxDecoration(
color:
score == 0
? Theme.of(
context,
).colorScheme.surfaceContainerHighest
: Theme.of(
context,
).colorScheme.primary.withValues(
alpha: 0.35 + score * 0.15,
),
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(height: 6),
Text(
day.substring(5),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
);
}).toList(),
),
],
),
);
}
}
+382 -1
View File
@@ -1,6 +1,30 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: 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: async:
dependency: transitive dependency: transitive
description: description:
@@ -17,6 +41,54 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.2" 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: characters:
dependency: transitive dependency: transitive
description: description:
@@ -25,6 +97,30 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.4.0" 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: clock:
dependency: transitive dependency: transitive
description: description:
@@ -41,6 +137,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.19.1" 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: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -49,6 +161,30 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.0.8" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -73,6 +209,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "7.0.1" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -86,6 +230,11 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -96,6 +245,62 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -128,6 +333,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "5.1.1" 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: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -152,14 +365,54 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
path: mime:
dependency: transitive 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: description:
name: path name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.9.1" 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: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -200,6 +453,38 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.8" 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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -256,11 +541,35 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.4.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -269,6 +578,30 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.10.1" 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: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -285,6 +618,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "2.1.4" 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: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -309,6 +650,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.7.4" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -325,6 +674,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "14.3.1" 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: web:
dependency: transitive dependency: transitive
description: description:
@@ -333,6 +690,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.1.1" 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: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -341,6 +714,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.1.0" 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: sdks:
dart: ">=3.7.2 <4.0.0" dart: ">=3.7.2 <4.0.0"
flutter: ">=3.29.0" flutter: ">=3.29.0"
+15 -1
View File
@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.7.2 sdk: ^3.7.2
@@ -30,15 +30,24 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
intl: any
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
drift: ^2.31.0
sqlite3_flutter_libs: ^0.5.42
path_provider: ^2.1.5
path: ^1.9.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
build_runner: ^2.10.4
drift_dev: ^2.31.0
# The "flutter_lints" package below contains a set of recommended lints to # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # 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. # The following section is specific to Flutter packages.
flutter: flutter:
generate: true
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in # included with your application, so that you can use the icons in
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
assets:
- assets/illustrations/onboarding/
- assets/illustrations/records/
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_seed/src/core/ambience.dart';
void main() {
group('dayPhaseFor', () {
test('uses the documented local time boundaries', () {
expect(dayPhaseFor(DateTime(2026, 6, 11, 4, 59)), DayPhase.night);
expect(dayPhaseFor(DateTime(2026, 6, 11, 5)), DayPhase.dawn);
expect(dayPhaseFor(DateTime(2026, 6, 11, 10)), DayPhase.day);
expect(dayPhaseFor(DateTime(2026, 6, 11, 17)), DayPhase.dusk);
expect(dayPhaseFor(DateTime(2026, 6, 11, 21)), DayPhase.night);
});
test('schedules the next boundary without background polling', () {
expect(
nextDayPhaseBoundary(DateTime(2026, 6, 11, 21, 15)),
DateTime(2026, 6, 12, 5),
);
expect(
nextDayPhaseBoundary(DateTime(2026, 6, 11, 8)),
DateTime(2026, 6, 11, 10),
);
});
});
}
+177
View File
@@ -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);
});
}
+130
View File
@@ -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
View File
@@ -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:flutter_test/flutter_test.dart';
import 'package:focus_seed/src/app/focus_seed_app.dart'; import 'package:focus_seed/src/app/focus_seed_app.dart';
import 'package: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'; import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
testWidgets('Focus Seed opens to the today screen', (tester) async { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
});
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(); await tester.pumpAndSettle();
expect(find.text('Focus Seed'), findsOneWidget); expect(find.text('Focus Seed'), findsOneWidget);
expect(find.text('Today anchor'), findsOneWidget); expect(find.text('Start with 10 minutes.'), findsNothing);
expect(find.text('Start focus'), findsOneWidget);
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'));
}