首次远程推送

This commit is contained in:
2026-09-27 23:40:22 +08:00
parent 2e0bf3c784
commit 8afa47952a
70 changed files with 8603 additions and 4502 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

+2
View File
@@ -1,5 +1,7 @@
# Focus Seed 1.0.1 Architecture # 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 ## 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. 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

+32 -14
View File
@@ -1,6 +1,6 @@
# Focus Seed Visual System v1.0.2 # Focus Seed Visual System v1.0.2
Status: planning contract. Do not treat this document as an implementation diff. Status: visual direction reference. Current product scope is defined in `../product-current-baseline.md`.
## Product Thesis ## Product Thesis
@@ -31,14 +31,14 @@ This direction is global-friendly, feasible in Flutter, serious enough for produ
## Target App Structure ## Target App Structure
Preferred primary navigation: Current V1 primary navigation:
- Today: daily action hub. - Today: daily action hub.
- Focus: immersive timer and active session. - Focus: immersive timer and active session.
- Habits: habit list, completion, weekly rhythm. - Records: focus sessions and reflection history.
- Growth: reflection, history, trends, milestones. - Me: preferences, privacy, and local data controls.
Settings should move behind a top-right profile/settings entry or a More sheet. It should not compete with the daily loop unless account, billing, or preferences become a core product surface. 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 Hierarchy
@@ -46,18 +46,22 @@ Settings should move behind a top-right profile/settings entry or a More sheet.
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 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 | | 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 | | Focus | Stay in one session | Current task and timer | Pause/complete/start | Duration, mode, next break, session stats | Minimal scroll; controls stable |
| Habits | Complete and manage habits | Today progress and filters | Toggle habit / add habit | Weekly dots, streaks, edit/reorder | Dense list; sticky section controls optional | | Records | Review saved work | Focus/reflection history switch | Open Today when empty | Sessions, notes, simple trends | Timeline/list; detail pages are V2 |
| Growth | Understand progress | Reflection entry or weekly summary | Reflect today / view timeline | Sessions, habits, records, trend charts | Timeline + cards; history can go to detail | | Me | Adjust app and privacy | Local data summary | Change preference | Privacy, clear data, language | Compact grouped list |
| Settings | Adjust app and privacy | Local data, appearance, language | Change preference | Privacy, clear data, version | Compact grouped list |
## Visual System ## Visual System
Palette: Palette:
- Primary: mature green or teal for progress and focus. - Brand: deep ink for identity, headings, and logo text. Do not use brand as a catch-all accent.
- Secondary: restrained blue/violet for reflection or insight. - 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. - Warm neutral surfaces instead of pure white where appropriate.
- Semantic colors for success, warning, destructive, disabled, offline. - Warning/destructive/disabled/offline must remain separate semantic colors.
- Dark mode must be designed, not inverted. - Dark mode must be designed, not inverted.
Typography: Typography:
@@ -79,6 +83,14 @@ Motifs:
- Use illustration in empty states, onboarding, growth, and rewards. - Use illustration in empty states, onboarding, growth, and rewards.
- Do not decorate every card. - 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 ## Interaction And Motion
Motion budget: Motion budget:
@@ -87,6 +99,12 @@ Motion budget:
- 15% spatial motion: tab/page/sheet/list transitions. - 15% spatial motion: tab/page/sheet/list transitions.
- 5% reward: session complete, streak, milestone. - 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: Required behavior:
- Tap feedback within 100 ms. - Tap feedback within 100 ms.
@@ -127,9 +145,9 @@ Create or consolidate:
1. Create design, component, and motion foundations. 1. Create design, component, and motion foundations.
2. Prototype Today and Focus first; review screenshots and interaction. 2. Prototype Today and Focus first; review screenshots and interaction.
3. Add the Habits screen as a real first-class surface. 3. Keep Records and Me honest until Habits/Growth have enough product depth to become primary tabs.
4. Rework Growth into reflection plus progress timeline. 4. Add detail pages only when they have data writes, navigation targets, and clear user outcomes.
5. Move Settings out of primary navigation or make it compact. 5. Keep Settings compact and user-facing; avoid developer-tool language.
6. Add empty/dense/error/offline/restored states. 6. Add empty/dense/error/offline/restored states.
7. Verify light/dark, English/Chinese, small phone, large text. 7. Verify light/dark, English/Chinese, small phone, large text.
8. Build and test Android release after each large module. 8. Build and test Android release after each large module.
-117
View File
@@ -1,117 +0,0 @@
# Focus Seed Visual Direction 1.0.1
## Decision
Use an editorial botanical interface with premium Soft 3D growth objects and state-driven Material Expressive motion.
The interface remains a mature productivity product. Illustration and 3D are reserved for progress, state, onboarding, empty states, and completion feedback rather than ordinary controls.
## Product Expression
- Core metaphor: intention plants a seed; focus, habits, and reflection grow it.
- Navigation: Today, Journey, Me.
- Today expands the current next action and compresses completed or future stages.
- Journey shows the relationship between daily actions and growth rather than a generic statistics dashboard.
- Ordinary lists, forms, dialogs, and settings stay compact and familiar.
## Visual Balance
- 72% functional editorial UI
- 18% Soft 3D state objects
- 10% botanical illustration, semantic accent, and motion
## Foundation Tokens
- Porcelain: `#F7F8F5`
- Ink: `#17211C`
- Leaf: `#4D8B63`
- Mint: `#A9E8B5`
- Ultramarine: `#4E63D8`
- Coral: `#EA725E`
- Radii: `8`, `12`, `16`
- Spacing: four-point base with an eight-point layout rhythm
- Elevation: two restrained levels plus hairline separators
- Body type: 14-16 logical pixels with compact headings
## Growth States
- Seed: no intention
- Sprout: intention saved
- Leaves: focus and habit progress
- Flower: reflection complete
Every state needs a non-color cue. Reduced-motion mode replaces growth animation with crossfade and an immediate state swap.
## Living Ambience System
Use stable structure with slowly changing ambience. Time must never move controls, change navigation, alter semantic meaning, or reduce contrast.
### Local Time Modes
- Dawn, `05:00-09:59`: cool porcelain, pale sky-mint surface tint, low-stimulation entrance, dew highlight on seed or sprout.
- Day, `10:00-16:59`: clean porcelain, highest information clarity, leaf and ultramarine accents.
- Dusk, `17:00-20:59`: restrained coral-lilac tint, softer localized shadows, reflection receives more visual priority when pending.
- Night, `21:00-04:59`: warm graphite, off-white text, mint primary, muted violet reflection accent, no bright glow.
Determine the mode from the device clock. Do not request location or network access. Re-evaluate on launch, resume, and the next time boundary; never run a background polling loop.
### Priority Order
1. Accessibility and contrast requirements
2. Explicit user Light, Dark, or System appearance preference
3. Platform reduced-motion and high-contrast settings
4. Time ambience within the selected appearance
5. Product growth state
If the user forces Light or Dark, preserve that appearance and vary only compatible neutral tints and object lighting. System appearance always wins over an automatic day/night surface switch.
### Week And Season
- Weekday/weekend may adjust optional greeting copy and recommended session length, but must not rearrange the interface.
- Do not infer physical season from month alone for a global audience because hemispheres differ.
- Offer optional Growth Seasons as cosmetic progression chapters. They are driven by product progress or an explicit user choice, not location.
- Avoid weather, religious, national, and holiday imagery unless the user explicitly enables a localized pack.
## Motion Contract
- Press response: `120ms`
- Ordinary state transition: `180-240ms`
- Growth transformation: `360-480ms`
- One-time completion celebration: no more than `700ms`
- Page transition: preserve spatial continuity and remain under `300ms`
Motion events:
- Intention saved: seed rises, settles, and becomes a sprout.
- Focus starts: progress begins; plant movement is nearly imperceptible.
- Habit completes: checkbox morphs to a leaf and one leaf unfolds.
- Reflection saves: flower opens once, then remains still.
- Background restoration: timer and plant immediately snap to correct data, followed by a short confirmation.
Never run continuous decorative motion. Pause nonessential animation when the app is inactive, during scrolling, under battery-saving conditions, or when reduced motion is enabled. Reduced motion uses crossfade, opacity, and immediate state replacement.
## Variation Budget
- Structure and navigation: `0%` time-based variation
- Semantic colors and action meaning: `0%` variation
- Neutral surfaces and background tint: subtle variation only
- Plant state: driven by real user progress
- Illustration: reserved for onboarding, empty states, milestones, and completion
- Copy: may vary by time while preserving the same meaning and action
The goal is recognition with freshness, not novelty on every launch.
## Guardrails
- No childish faces or mascot behavior.
- No oversized 3D hero or giant timer.
- No card-per-section dashboard.
- No decorative illustration without state meaning.
- No animation that delays input or hides status.
- Validate long localization, 200% text, dense lists, keyboard, offline restoration, loading, and recoverable errors.
## Visual Reference
See `focus-seed-visual-direction-v1.0.1.png` in this directory.
See `focus-seed-living-ambience-v1.0.1.png` for time modes and motion behavior.
@@ -1,5 +1,7 @@
# Focus Seed 1.0.2 Local Database Product Loop # 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 ## 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. - 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.
+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
+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:
+87 -110
View File
@@ -4,33 +4,38 @@ import '../core/ambience.dart';
class AppTheme { class AppTheme {
static const ink = Color(0xFF17211C); static const ink = Color(0xFF17211C);
static const leaf = Color(0xFF4D8B63); static const forest = Color(0xFF08785B);
static const mint = Color(0xFFA9E8B5); static const teal = Color(0xFF0A8179);
static const ultramarine = Color(0xFF4E63D8); static const moss = Color(0xFF6C8F6B);
static const coral = Color(0xFFEA725E); static const mint = Color(0xFFA8E6C1);
static const violet = Color(0xFF7856D8); static const ultramarine = Color(0xFF2E6FDB);
static const porcelain = Color(0xFFF7F8F5); 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(DayPhase phase) { static ThemeData light(DayPhase phase) {
final surface = switch (phase) { final surface = switch (phase) {
DayPhase.dawn => const Color(0xFFF3F8F7), DayPhase.dawn => const Color(0xFFF3F8FC),
DayPhase.day => porcelain, DayPhase.day => porcelain,
DayPhase.dusk => const Color(0xFFFBF5F2), DayPhase.dusk => const Color(0xFFFBF6F4),
DayPhase.night => const Color(0xFFF3F5F3), DayPhase.night => const Color(0xFFF2F5FA),
}; };
final scheme = ColorScheme.fromSeed(seedColor: leaf).copyWith( final scheme = ColorScheme.fromSeed(seedColor: ultramarine).copyWith(
primary: leaf, primary: ultramarine,
onPrimary: Colors.white, onPrimary: Colors.white,
secondary: ultramarine, secondary: ultramarine,
tertiary: coral, tertiary: amber,
surface: surface, surface: surface,
onSurface: ink, onSurface: ink,
surfaceContainerLowest: Colors.white.withValues(alpha: 0.82), surfaceContainerLowest: const Color(0xFFFFFEFA),
surfaceContainerLow: Colors.white.withValues(alpha: 0.72), surfaceContainerLow: const Color(0xFFEFF3FA),
surfaceContainer: const Color(0xFFF0F3EF), surfaceContainer: const Color(0xFFE4EAF4),
surfaceContainerHigh: const Color(0xFFE9EEE9), surfaceContainerHigh: const Color(0xFFD6DEEB),
outline: const Color(0xFFB9C2BA), outline: const Color(0xFF9EA9B8),
outlineVariant: const Color(0xFFDDE3DD), outlineVariant: const Color(0xFFD5DEEA),
); );
return _base(scheme, phase); return _base(scheme, phase);
} }
@@ -42,50 +47,32 @@ class AppTheme {
DayPhase.dusk => const Color(0xFF1A181A), DayPhase.dusk => const Color(0xFF1A181A),
DayPhase.night => const Color(0xFF101614), DayPhase.night => const Color(0xFF101614),
}; };
final scheme = ColorScheme.fromSeed( final scheme = ColorScheme.fromSeed(seedColor: mint, brightness: Brightness.dark).copyWith(
seedColor: mint, primary: const Color(0xFF8FBAFF),
brightness: Brightness.dark, onPrimary: const Color(0xFF071226),
).copyWith( secondary: const Color(0xFF8FBAFF),
primary: mint, tertiary: const Color(0xFFFFD782),
onPrimary: ink,
secondary: const Color(0xFFB9C2FF),
tertiary: const Color(0xFFFFB4A8),
surface: surface, surface: surface,
onSurface: const Color(0xFFF1F5F1), onSurface: const Color(0xFFF1F5F1),
surfaceContainerLowest: const Color(0xFF0D1210), surfaceContainerLowest: const Color(0xFF0D1210),
surfaceContainerLow: const Color(0xFF171E1B), surfaceContainerLow: const Color(0xFF151C24),
surfaceContainer: const Color(0xFF1C2420), surfaceContainer: const Color(0xFF1B2430),
surfaceContainerHigh: const Color(0xFF243029), surfaceContainerHigh: const Color(0xFF253142),
outline: const Color(0xFF7E8D83), outline: const Color(0xFF7F8EA4),
outlineVariant: const Color(0xFF344039), outlineVariant: const Color(0xFF344153),
); );
return _base(scheme, phase); return _base(scheme, phase);
} }
static ThemeData _base(ColorScheme scheme, DayPhase phase) { static ThemeData _base(ColorScheme scheme, DayPhase phase) {
final base = ThemeData( final base = ThemeData(useMaterial3: true, colorScheme: scheme, scaffoldBackgroundColor: scheme.surface);
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
);
final textTheme = base.textTheme.copyWith( final textTheme = base.textTheme.copyWith(
headlineSmall: base.textTheme.headlineSmall?.copyWith( headlineSmall: base.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700, height: 1.15),
fontWeight: FontWeight.w700, titleLarge: base.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700, height: 1.2),
height: 1.15, titleMedium: base.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, height: 1.25),
),
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), bodyLarge: base.textTheme.bodyLarge?.copyWith(height: 1.4),
bodyMedium: base.textTheme.bodyMedium?.copyWith(height: 1.4), bodyMedium: base.textTheme.bodyMedium?.copyWith(height: 1.4),
labelLarge: base.textTheme.labelLarge?.copyWith( labelLarge: base.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
fontWeight: FontWeight.w600,
),
); );
return base.copyWith( return base.copyWith(
textTheme: textTheme, textTheme: textTheme,
@@ -94,34 +81,22 @@ class AppTheme {
elevation: 0, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
color: scheme.surfaceContainerLowest, color: scheme.surfaceContainerLowest,
shadowColor: ink.withValues(alpha: 0.12), shadowColor: ink.withValues(alpha: 0.10),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: scheme.outlineVariant),
),
), ),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
height: 68, height: 72,
elevation: 0, elevation: 0,
backgroundColor: scheme.surfaceContainerLowest, backgroundColor: scheme.surfaceContainerLowest,
indicatorColor: scheme.primary.withValues(alpha: 0.14), indicatorColor: scheme.primary.withValues(alpha: 0.13),
labelTextStyle: WidgetStateProperty.resolveWith( iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData(size: 23, color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant)),
(states) => TextStyle( labelTextStyle: WidgetStateProperty.resolveWith((states) => TextStyle(fontSize: 12, fontWeight: states.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500)),
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),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(fontWeight: FontWeight.w700), textStyle: const TextStyle(fontWeight: FontWeight.w700),
), ),
), ),
@@ -129,47 +104,24 @@ class AppTheme {
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size(48, 48), minimumSize: const Size(48, 48),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(12),
),
), ),
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: scheme.surfaceContainerLow, fillColor: scheme.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
horizontal: 14, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.22))),
vertical: 13, enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.22))),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: scheme.outlineVariant),
),
),
chipTheme: base.chipTheme.copyWith(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
side: BorderSide(color: scheme.outlineVariant),
),
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
space: 1,
thickness: 1,
),
dialogTheme: DialogTheme(
backgroundColor: scheme.surfaceContainerLowest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
), ),
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( bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surface, backgroundColor: scheme.surface,
modalBackgroundColor: scheme.surface, modalBackgroundColor: scheme.surface,
showDragHandle: true, showDragHandle: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
), ),
extensions: [FocusSeedVisuals.forPhase(phase, scheme.brightness)], extensions: [FocusSeedVisuals.forPhase(phase, scheme.brightness)],
); );
@@ -180,14 +132,26 @@ class AppTheme {
class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> { class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
const FocusSeedVisuals({ const FocusSeedVisuals({
required this.ambientTint, required this.ambientTint,
required this.brand,
required this.action,
required this.focus,
required this.habit,
required this.reflection, required this.reflection,
required this.success, required this.success,
required this.privacy,
required this.warning,
required this.shadow, required this.shadow,
}); });
final Color ambientTint; final Color ambientTint;
final Color brand;
final Color action;
final Color focus;
final Color habit;
final Color reflection; final Color reflection;
final Color success; final Color success;
final Color privacy;
final Color warning;
final Color shadow; final Color shadow;
static FocusSeedVisuals of(BuildContext context) { static FocusSeedVisuals of(BuildContext context) {
@@ -203,24 +167,31 @@ class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
DayPhase.night => const Color(0xFF9A89D8), DayPhase.night => const Color(0xFF9A89D8),
}; };
return FocusSeedVisuals( return FocusSeedVisuals(
ambientTint: ambientTint.withValues(alpha: isDark ? 0.18 : 0.22), ambientTint: ambientTint.withValues(alpha: isDark ? 0.14 : 0.18),
reflection: isDark ? const Color(0xFFBDAAFF) : AppTheme.violet, brand: isDark ? const Color(0xFFF1F5F1) : AppTheme.ink,
success: isDark ? AppTheme.mint : AppTheme.leaf, 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), shadow: Colors.black.withValues(alpha: isDark ? 0.32 : 0.10),
); );
} }
@override @override
FocusSeedVisuals copyWith({ FocusSeedVisuals copyWith({Color? ambientTint, Color? brand, Color? action, Color? focus, Color? habit, Color? reflection, Color? success, Color? privacy, Color? warning, Color? shadow}) {
Color? ambientTint,
Color? reflection,
Color? success,
Color? shadow,
}) {
return FocusSeedVisuals( return FocusSeedVisuals(
ambientTint: ambientTint ?? this.ambientTint, ambientTint: ambientTint ?? this.ambientTint,
brand: brand ?? this.brand,
action: action ?? this.action,
focus: focus ?? this.focus,
habit: habit ?? this.habit,
reflection: reflection ?? this.reflection, reflection: reflection ?? this.reflection,
success: success ?? this.success, success: success ?? this.success,
privacy: privacy ?? this.privacy,
warning: warning ?? this.warning,
shadow: shadow ?? this.shadow, shadow: shadow ?? this.shadow,
); );
} }
@@ -232,8 +203,14 @@ class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
} }
return FocusSeedVisuals( return FocusSeedVisuals(
ambientTint: Color.lerp(ambientTint, other.ambientTint, t)!, 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)!, reflection: Color.lerp(reflection, other.reflection, t)!,
success: Color.lerp(success, other.success, 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)!, shadow: Color.lerp(shadow, other.shadow, t)!,
); );
} }
+57 -4
View File
@@ -7,10 +7,12 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import '../core/ambience.dart'; import '../core/ambience.dart';
import '../data/app_preferences_store.dart'; import '../data/app_preferences_store.dart';
import '../data/focus_seed_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 '../l10n/generated/app_localizations.dart';
import '../models/appearance_preference.dart'; import '../models/appearance_preference.dart';
import '../models/language_preference.dart'; import '../models/language_preference.dart';
import '../widgets/loading_view.dart';
import 'app_theme.dart'; import 'app_theme.dart';
@visibleForTesting @visibleForTesting
@@ -19,10 +21,16 @@ Locale focusSeedDefaultLocale({bool isRelease = kReleaseMode}) {
} }
class FocusSeedApp extends StatefulWidget { class FocusSeedApp extends StatefulWidget {
const FocusSeedApp({this.locale, this.focusSeedStore, super.key}); const FocusSeedApp({
this.locale,
this.focusSeedStore,
this.showWelcomeOnFirstRun = true,
super.key,
});
final Locale? locale; final Locale? locale;
final FocusSeedStore? focusSeedStore; final FocusSeedStore? focusSeedStore;
final bool showWelcomeOnFirstRun;
@override @override
State<FocusSeedApp> createState() => _FocusSeedAppState(); State<FocusSeedApp> createState() => _FocusSeedAppState();
@@ -33,6 +41,10 @@ class _FocusSeedAppState extends State<FocusSeedApp>
final _preferencesStore = AppPreferencesStore(); final _preferencesStore = AppPreferencesStore();
AppearancePreference _appearance = AppearancePreference.system; AppearancePreference _appearance = AppearancePreference.system;
LanguagePreference _language = LanguagePreference.defaultLocale; LanguagePreference _language = LanguagePreference.defaultLocale;
bool _onboardingComplete = true;
bool _landingSetupComplete = true;
bool _showLandingSetup = false;
bool _preferencesLoaded = false;
DayPhase _dayPhase = dayPhaseFor(DateTime.now()); DayPhase _dayPhase = dayPhaseFor(DateTime.now());
Timer? _phaseTimer; Timer? _phaseTimer;
@@ -52,14 +64,25 @@ class _FocusSeedAppState extends State<FocusSeedApp>
} }
Future<void> _loadPreferences() async { Future<void> _loadPreferences() async {
final appearance = await _preferencesStore.loadAppearance(); final (appearance, language, firstRunProgress) =
final language = await _preferencesStore.loadLanguage(); await (
_preferencesStore.loadAppearance(),
_preferencesStore.loadLanguage(),
_preferencesStore.loadFirstRunProgress(),
).wait;
if (!mounted) { if (!mounted) {
return; return;
} }
setState(() { setState(() {
_appearance = appearance; _appearance = appearance;
_language = language; _language = language;
_onboardingComplete = firstRunProgress.onboardingComplete;
_landingSetupComplete = firstRunProgress.landingSetupComplete;
_showLandingSetup =
widget.showWelcomeOnFirstRun &&
firstRunProgress.onboardingComplete &&
!firstRunProgress.landingSetupComplete;
_preferencesLoaded = true;
}); });
} }
@@ -98,6 +121,22 @@ class _FocusSeedAppState extends State<FocusSeedApp>
return widget.locale ?? _language.locale ?? focusSeedDefaultLocale(); 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(
@@ -115,13 +154,27 @@ class _FocusSeedAppState extends State<FocusSeedApp>
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
], ],
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: FocusSeedHome( home:
!_preferencesLoaded
? const Scaffold(body: SafeArea(child: LoadingView()))
: widget.showWelcomeOnFirstRun && !_onboardingComplete
? WelcomeScreen(
onStart: _completeOnboarding,
onSkip: _completeOnboarding,
)
: FocusSeedHome(
focusSeedStore: widget.focusSeedStore, focusSeedStore: widget.focusSeedStore,
appearance: _appearance, appearance: _appearance,
onAppearanceChanged: _setAppearance, onAppearanceChanged: _setAppearance,
language: _language, language: _language,
showLanguageTools: !kReleaseMode && widget.locale == null, showLanguageTools: !kReleaseMode && widget.locale == null,
onLanguageChanged: _setLanguage, onLanguageChanged: _setLanguage,
showLandingSetupInitially:
widget.showWelcomeOnFirstRun &&
(!_landingSetupComplete || _showLandingSetup),
onLandingSetupComplete: () {
unawaited(_completeLandingSetup());
},
), ),
); );
} }
+70 -13
View File
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../core/date_keys.dart'; import '../core/date_keys.dart';
import '../core/focus_durations.dart';
import '../data/focus_seed_store.dart'; import '../data/focus_seed_store.dart';
import '../models/app_state.dart'; import '../models/app_state.dart';
import '../models/focus_session.dart'; import '../models/focus_session.dart';
@@ -55,12 +56,18 @@ class FocusSeedController extends ChangeNotifier {
Future<bool> setTodayAnchor(String value) async { Future<bool> setTodayAnchor(String value) async {
final current = _state; final current = _state;
final anchor = value.trim(); final title = value.trim();
if (current == null || anchor.isEmpty) { if (current == null || title.isEmpty) {
return false; return false;
} }
_analytics.track('intention_saved'); _analytics.track('intention_saved');
return _commit(current.copyWith(anchor: anchor, anchorDateKey: todayKey())); return _commit(
current.copyWith(
anchor: title,
anchorDateKey: todayKey(),
selectedActionId: '',
),
);
} }
Future<bool> toggleHabit(Habit habit) async { Future<bool> toggleHabit(Habit habit) async {
@@ -128,46 +135,86 @@ class FocusSeedController extends ChangeNotifier {
return _commit(current.copyWith(habits: habits)); return _commit(current.copyWith(habits: habits));
} }
Future<bool> completeFocusSession(int minutes) async { Future<bool> completeFocusSession(int minutes, {int? actualSeconds}) async {
final current = _state; final current = _state;
if (current == null) { if (current == null) {
return false; return false;
} }
final now = DateTime.now(); final now = DateTime.now();
final today = todayKey();
final anchor = current.activeAnchor(today);
final session = FocusSession( final session = FocusSession(
id: 'session-${now.microsecondsSinceEpoch}', id: 'session-${now.microsecondsSinceEpoch}',
minutes: minutes, minutes: minutes,
actualSeconds: actualSeconds,
completedAt: now, 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],
),
); );
_analytics.track('focus_completed', {'minutes': minutes});
return _commit(current.copyWith(sessions: [...current.sessions, session]));
} }
Future<bool> saveReflection(String value, Mood mood) async { 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 current = _state;
final note = value.trim(); final trimmedNote = value.trim();
if (current == null || note.isEmpty) { final targetDateKey = dateKey.trim();
if (current == null || targetDateKey.isEmpty || trimmedNote.isEmpty) {
return false; return false;
} }
final today = todayKey();
final now = DateTime.now(); final now = DateTime.now();
final entry = ReflectionEntry( final entry = ReflectionEntry(
id: 'reflection-${now.microsecondsSinceEpoch}', id: 'reflection-${now.microsecondsSinceEpoch}',
dateKey: today, dateKey: targetDateKey,
note: note, note: trimmedNote,
mood: mood, mood: mood,
createdAt: now, createdAt: now,
); );
final reflections = [ final reflections = [
...current.reflections.where((item) => item.dateKey != today), ...current.reflections.where((item) => item.dateKey != targetDateKey),
entry, entry,
]; ];
_analytics.track('reflection_saved'); _analytics.track('reflection_saved');
return _commit(current.copyWith(reflections: reflections)); 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 { Future<bool> clearData() async {
return _commit(AppState.starter(defaultHabitTitles: _defaultHabitTitles)); final current = _state;
return _commit(
AppState.starter(defaultHabitTitles: _defaultHabitTitles).copyWith(
defaultFocusMinutes:
current?.defaultFocusMinutes ?? defaultFocusDurationMinutes,
),
);
} }
Future<bool> clearToday() async { Future<bool> clearToday() async {
@@ -190,12 +237,22 @@ class FocusSeedController extends ChangeNotifier {
final reflections = current.reflections final reflections = current.reflections
.where((reflection) => reflection.dateKey != today) .where((reflection) => reflection.dateKey != today)
.toList(growable: false); .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'); _analytics.track('today_cleared');
return _commit( return _commit(
current.copyWith( current.copyWith(
anchor: current.anchorDateKey == today ? '' : current.anchor, anchor: current.anchorDateKey == today ? '' : current.anchor,
selectedActionId:
current.anchorDateKey == today ? '' : current.selectedActionId,
habits: habits, habits: habits,
actions: actions,
sessions: sessions, sessions: sessions,
progressNotes: progressNotes,
reflections: reflections, reflections: reflections,
), ),
); );
+147 -5
View File
@@ -3,21 +3,30 @@ import 'dart:math' as math;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../core/focus_durations.dart';
import '../data/focus_timer_session_store.dart';
enum FocusTimerStatus { idle, running, paused, completed } enum FocusTimerStatus { idle, running, paused, completed }
typedef FocusTimerCompleted =
Future<bool> Function(int minutes, int actualSeconds);
class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver { class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
FocusTimerController({ FocusTimerController({
required Future<void> Function(int minutes) onCompleted, required FocusTimerCompleted onCompleted,
int initialMinutes = 25, FocusTimerSessionStore? sessionStore,
int initialMinutes = defaultFocusDurationMinutes,
}) : _onCompleted = onCompleted, }) : _onCompleted = onCompleted,
_sessionStore = sessionStore,
_selectedMinutes = initialMinutes, _selectedMinutes = initialMinutes,
_remainingSeconds = initialMinutes * 60 { _remainingSeconds = initialMinutes * 60 {
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
} }
static const presets = [15, 25, 45]; static const presets = focusDurationPresets;
final Future<void> Function(int minutes) _onCompleted; final FocusTimerCompleted _onCompleted;
final FocusTimerSessionStore? _sessionStore;
Timer? _ticker; Timer? _ticker;
DateTime? _endsAt; DateTime? _endsAt;
int _selectedMinutes; int _selectedMinutes;
@@ -27,6 +36,8 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
int get selectedMinutes => _selectedMinutes; int get selectedMinutes => _selectedMinutes;
int get remainingSeconds => _remainingSeconds; int get remainingSeconds => _remainingSeconds;
int get elapsedSeconds =>
math.max(0, (_selectedMinutes * 60) - _remainingSeconds);
FocusTimerStatus get status => _status; FocusTimerStatus get status => _status;
bool get isRunning => _status == FocusTimerStatus.running; bool get isRunning => _status == FocusTimerStatus.running;
bool get isPaused => _status == FocusTimerStatus.paused; bool get isPaused => _status == FocusTimerStatus.paused;
@@ -38,6 +49,53 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
: (1 - (_remainingSeconds / totalSeconds)).clamp(0, 1); : (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) { void selectMinutes(int minutes) {
if (isRunning || !presets.contains(minutes)) { if (isRunning || !presets.contains(minutes)) {
return; return;
@@ -58,6 +116,7 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
_status = FocusTimerStatus.running; _status = FocusTimerStatus.running;
_endsAt = DateTime.now().add(Duration(seconds: _remainingSeconds)); _endsAt = DateTime.now().add(Duration(seconds: _remainingSeconds));
_startTicker(); _startTicker();
unawaited(_persistActiveSession());
notifyListeners(); notifyListeners();
} }
@@ -69,6 +128,7 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
_ticker?.cancel(); _ticker?.cancel();
_endsAt = null; _endsAt = null;
_status = FocusTimerStatus.paused; _status = FocusTimerStatus.paused;
unawaited(_persistActiveSession());
notifyListeners(); notifyListeners();
} }
@@ -77,6 +137,7 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
_endsAt = null; _endsAt = null;
_remainingSeconds = _selectedMinutes * 60; _remainingSeconds = _selectedMinutes * 60;
_status = FocusTimerStatus.idle; _status = FocusTimerStatus.idle;
unawaited(_clearSavedSession());
notifyListeners(); notifyListeners();
} }
@@ -118,19 +179,100 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
if (_isCompleting || isCompleted) { if (_isCompleting || isCompleted) {
return; return;
} }
if (isRunning) {
_refreshRemainingTime();
}
final totalSeconds = _selectedMinutes * 60;
final actualSeconds =
_remainingSeconds <= 0
? totalSeconds
: math.max(1, totalSeconds - _remainingSeconds);
_isCompleting = true; _isCompleting = true;
_ticker?.cancel(); _ticker?.cancel();
_endsAt = null; _endsAt = null;
_remainingSeconds = 0; _remainingSeconds = 0;
_status = FocusTimerStatus.completed; _status = FocusTimerStatus.completed;
unawaited(_persistCompletedSession(actualSeconds));
notifyListeners(); notifyListeners();
try { try {
await _onCompleted(_selectedMinutes); final saved = await _onCompleted(_selectedMinutes, actualSeconds);
if (saved) {
await _clearSavedSession();
}
} finally { } finally {
_isCompleting = false; _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 @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
+1 -1
View File
@@ -22,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(
+43
View File
@@ -3,9 +3,22 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/appearance_preference.dart'; import '../models/appearance_preference.dart';
import '../models/language_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 { class AppPreferencesStore {
static const _appearanceKey = 'focus_seed_appearance'; static const _appearanceKey = 'focus_seed_appearance';
static const _languageKey = 'focus_seed_language'; 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 { Future<AppearancePreference> loadAppearance() async {
final preferences = await SharedPreferences.getInstance(); final preferences = await SharedPreferences.getInstance();
@@ -26,4 +39,34 @@ class AppPreferencesStore {
final preferences = await SharedPreferences.getInstance(); final preferences = await SharedPreferences.getInstance();
await preferences.setString(_languageKey, language.name); 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);
}
} }
+187 -6
View File
@@ -4,10 +4,13 @@ import 'package:drift/drift.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../core/date_keys.dart' as date_keys; 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/focus_session.dart';
import '../models/habit.dart'; import '../models/habit.dart';
import '../models/mood.dart'; import '../models/mood.dart';
import '../models/progress_note.dart';
import '../models/reflection_entry.dart'; import '../models/reflection_entry.dart';
import 'local/focus_seed_database.dart'; import 'local/focus_seed_database.dart';
@@ -17,6 +20,7 @@ class FocusSeedStore {
static const _legacyStorageKey = 'focus_seed_state_v1'; static const _legacyStorageKey = 'focus_seed_state_v1';
static const _legacyMigrationKey = 'focus_seed_state_v1_migrated_to_sqlite'; static const _legacyMigrationKey = 'focus_seed_state_v1_migrated_to_sqlite';
static const _defaultFocusMinutesKey = 'default_focus_minutes';
final FocusSeedDatabase _database; final FocusSeedDatabase _database;
@@ -35,25 +39,56 @@ class FocusSeedStore {
Future<void> save(AppState state) async { Future<void> save(AppState state) async {
await _database.transaction(() async { await _database.transaction(() async {
await _database.delete(_database.habitLogRows).go(); await _database.delete(_database.habitLogRows).go();
await _database.delete(_database.progressNoteRows).go();
await _database.delete(_database.reflectionRows).go(); await _database.delete(_database.reflectionRows).go();
await _database.delete(_database.focusSessionRows).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.habitRows).go();
await _database.delete(_database.dailyIntentionRows).go(); await _database.delete(_database.dailyIntentionRows).go();
if (state.anchor.trim().isNotEmpty) { 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(); final now = DateTime.now();
await _database await _database
.into(_database.dailyIntentionRows) .into(_database.dailyIntentionRows)
.insert( .insert(
DailyIntentionRowsCompanion.insert( DailyIntentionRowsCompanion.insert(
dateKey: state.anchorDateKey, dateKey: state.anchorDateKey,
intentionText: state.anchor, intentionText: anchorText,
createdAt: now, createdAt: now,
updatedAt: 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) { for (final (index, habit) in state.habits.indexed) {
await _database await _database
.into(_database.habitRows) .into(_database.habitRows)
@@ -85,6 +120,12 @@ class FocusSeedStore {
.insert(_focusSessionCompanion(session)); .insert(_focusSessionCompanion(session));
} }
for (final note in state.progressNotes) {
await _database
.into(_database.progressNoteRows)
.insert(_progressNoteCompanion(note));
}
for (final reflection in state.reflections) { for (final reflection in state.reflections) {
await _database await _database
.into(_database.reflectionRows) .into(_database.reflectionRows)
@@ -120,6 +161,12 @@ class FocusSeedStore {
..limit(1)) ..limit(1))
.get(); .get();
final latestIntention = intentionRows.firstOrNull; 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 = final habitRows =
await (_database.select(_database.habitRows) await (_database.select(_database.habitRows)
..where((row) => row.archivedAt.isNull()) ..where((row) => row.archivedAt.isNull())
@@ -134,18 +181,36 @@ class FocusSeedStore {
final focusSessionRows = final focusSessionRows =
await (_database.select(_database.focusSessionRows) await (_database.select(_database.focusSessionRows)
..orderBy([(row) => OrderingTerm.asc(row.endedAt)])).get(); ..orderBy([(row) => OrderingTerm.asc(row.endedAt)])).get();
final progressNoteRows =
await (_database.select(_database.progressNoteRows)
..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get();
final reflectionRows = final reflectionRows =
await (_database.select(_database.reflectionRows) await (_database.select(_database.reflectionRows)
..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get(); ..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>>{}; final completedDatesByHabit = <String, Set<String>>{};
for (final log in habitLogRows) { for (final log in habitLogRows) {
completedDatesByHabit.putIfAbsent(log.habitId, () => {}).add(log.dateKey); 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( return AppState(
anchor: latestIntention?.intentionText ?? '', anchor: anchor,
anchorDateKey: latestIntention?.dateKey ?? date_keys.todayKey(), anchorDateKey: anchorDateKey,
habits: [ habits: [
for (final habit in habitRows) for (final habit in habitRows)
Habit( Habit(
@@ -160,7 +225,12 @@ class FocusSeedStore {
FocusSession( FocusSession(
id: session.id, id: session.id,
minutes: session.plannedMinutes, minutes: session.plannedMinutes,
actualSeconds: session.actualSeconds,
completedAt: session.endedAt, completedAt: session.endedAt,
intention: session.intentionText ?? '',
actionId: session.actionId,
actionTitleSnapshot:
session.actionTitleSnapshot ?? session.intentionText,
), ),
], ],
reflections: [ reflections: [
@@ -176,9 +246,28 @@ class FocusSeedStore {
createdAt: reflection.createdAt, 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 { Future<void> _migrateLegacyJsonIfNeeded() async {
final preferences = await SharedPreferences.getInstance(); final preferences = await SharedPreferences.getInstance();
if (preferences.getBool(_legacyMigrationKey) ?? false) { if (preferences.getBool(_legacyMigrationKey) ?? false) {
@@ -206,6 +295,12 @@ class FocusSeedStore {
} }
Future<bool> _hasDatabaseContent() async { Future<bool> _hasDatabaseContent() async {
final action =
await (_database.select(_database.dailyActionRows)
..limit(1)).getSingleOrNull();
if (action != null) {
return true;
}
final intention = final intention =
await (_database.select(_database.dailyIntentionRows) await (_database.select(_database.dailyIntentionRows)
..limit(1)).getSingleOrNull(); ..limit(1)).getSingleOrNull();
@@ -224,20 +319,106 @@ class FocusSeedStore {
if (session != null) { if (session != null) {
return true; return true;
} }
final progress =
await (_database.select(_database.progressNoteRows)
..limit(1)).getSingleOrNull();
if (progress != null) {
return true;
}
final reflection = final reflection =
await (_database.select(_database.reflectionRows) await (_database.select(_database.reflectionRows)
..limit(1)).getSingleOrNull(); ..limit(1)).getSingleOrNull();
return reflection != null; 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) { FocusSessionRowsCompanion _focusSessionCompanion(FocusSession session) {
final completedAt = session.completedAt; final completedAt = session.completedAt;
return FocusSessionRowsCompanion.insert( return FocusSessionRowsCompanion.insert(
id: session.id, id: session.id,
dateKey: date_keys.dateKey(completedAt), 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, plannedMinutes: session.minutes,
actualSeconds: session.minutes * 60, actualSeconds: session.actualSeconds,
startedAt: completedAt.subtract(Duration(minutes: session.minutes)), startedAt: completedAt.subtract(Duration(seconds: session.actualSeconds)),
endedAt: completedAt, endedAt: completedAt,
status: 'completed', status: 'completed',
); );
+87 -1
View File
@@ -26,6 +26,10 @@ class FocusSessionRows extends Table {
TextColumn get id => text()(); TextColumn get id => text()();
TextColumn get dateKey => 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 plannedMinutes => integer()();
IntColumn get actualSeconds => integer()(); IntColumn get actualSeconds => integer()();
DateTimeColumn get startedAt => dateTime()(); DateTimeColumn get startedAt => dateTime()();
@@ -36,6 +40,55 @@ class FocusSessionRows extends Table {
Set<Column<Object>> get primaryKey => {id}; 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 { class HabitRows extends Table {
@override @override
String get tableName => 'habits'; String get tableName => 'habits';
@@ -92,7 +145,10 @@ class AppSettingRows extends Table {
@DriftDatabase( @DriftDatabase(
tables: [ tables: [
DailyIntentionRows, DailyIntentionRows,
DailyActionRows,
FocusSessionRows, FocusSessionRows,
ProgressNoteRows,
RecentLabelRows,
HabitRows, HabitRows,
HabitLogRows, HabitLogRows,
ReflectionRows, ReflectionRows,
@@ -104,13 +160,43 @@ class FocusSeedDatabase extends _$FocusSeedDatabase {
: super(executor ?? _openConnection()); : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 1; 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 { Future<void> clearProductData() async {
await transaction(() async { await transaction(() async {
await delete(habitLogRows).go(); await delete(habitLogRows).go();
await delete(progressNoteRows).go();
await delete(reflectionRows).go(); await delete(reflectionRows).go();
await delete(focusSessionRows).go(); await delete(focusSessionRows).go();
await delete(recentLabelRows).go();
await delete(dailyActionRows).go();
await delete(habitRows).go(); await delete(habitRows).go();
await delete(dailyIntentionRows).go(); await delete(dailyIntentionRows).go();
}); });
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../app/app_theme.dart';
import '../../application/focus_seed_controller.dart'; import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart'; import '../../core/date_keys.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import '../../widgets/app_bottom_sheet.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/dialogs.dart'; import '../../widgets/dialogs.dart';
import '../../widgets/loading_view.dart';
import '../today/widgets/compact_habit_row.dart'; import '../today/widgets/compact_habit_row.dart';
Future<void> showHabitManagerSheet( Future<void> showHabitManagerSheet(
@@ -21,64 +25,42 @@ Future<void> showHabitManagerSheet(
animation: controller, animation: controller,
builder: (context, _) { builder: (context, _) {
final state = controller.state; final state = controller.state;
if (state == null) {
return const Center(child: CircularProgressIndicator());
}
final l10n = context.l10n; 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(); final today = todayKey();
return Column( return AppBottomSheetScaffold(
children: [ title: l10n.habitsTitle,
Padding( subtitle: l10n.habitsSubtitle,
padding: const EdgeInsets.fromLTRB(20, 4, 12, 12), icon: Icons.add_task_rounded,
child: Row( accent: visuals.habit,
children: [ expand: true,
Expanded( trailing: IconButton.filledTonal(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.habitsTitle,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
l10n.habitsSubtitle,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton.filledTonal(
tooltip: l10n.addHabit, tooltip: l10n.addHabit,
onPressed: onPressed:
() => () => showHabitDialog(context, controller.addHabit),
showHabitDialog(context, controller.addHabit),
icon: const Icon(Icons.add_rounded), icon: const Icon(Icons.add_rounded),
), ),
],
),
),
const Divider(),
Expanded(
child: child:
state.habits.isEmpty state.habits.isEmpty
? _EmptyHabits( ? _EmptyHabits(
onAdd: onAdd:
() => showHabitDialog( () =>
context, showHabitDialog(context, controller.addHabit),
controller.addHabit,
),
) )
: ListView.separated( : ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32), padding: const EdgeInsets.fromLTRB(0, 2, 0, 24),
itemCount: state.habits.length, itemCount: state.habits.length,
separatorBuilder: (_, _) => const Divider(), separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final habit = state.habits[index]; final habit = state.habits[index];
return CompactHabitRow( return CompactHabitRow(
@@ -88,10 +70,8 @@ Future<void> showHabitManagerSheet(
onEdit: onEdit:
() => showHabitDialog( () => showHabitDialog(
context, context,
(value) => controller.renameHabit( (value) =>
habit, controller.renameHabit(habit, value),
value,
),
initialValue: habit.title, initialValue: habit.title,
), ),
onDelete: onDelete:
@@ -102,8 +82,6 @@ Future<void> showHabitManagerSheet(
); );
}, },
), ),
),
],
); );
}, },
), ),
@@ -118,31 +96,17 @@ class _EmptyHabits extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( final l10n = context.l10n;
child: Padding( 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), padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.eco_outlined,
size: 40,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 12),
Text(
context.l10n.emptyHabitsTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add_rounded),
label: Text(context.l10n.addHabit),
),
],
),
),
); );
} }
} }
@@ -1,411 +0,0 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../l10n/l10n.dart';
import '../../models/focus_session.dart';
import '../../models/reflection_entry.dart';
import '../../widgets/seed_illustration.dart';
import '../../widgets/surface_panel.dart';
enum _HistoryView { focus, notes }
class HistoryScreen extends StatefulWidget {
const HistoryScreen({required this.controller, super.key});
final FocusSeedController controller;
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
_HistoryView _view = _HistoryView.focus;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(title: Text(l10n.historyTitle)),
body: SafeArea(
top: false,
child: AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final state = widget.controller.state!;
final focusSessions = state.sessions.reversed.toList();
final reflections = state.reflections.reversed.toList();
return ListView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
children: [
_HistorySwitch(
value: _view,
onChanged: (value) => setState(() => _view = value),
),
const SizedBox(height: 10),
AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child:
_view == _HistoryView.focus
? _FocusHistoryList(
key: const ValueKey('focus-history'),
sessions: focusSessions,
onStartToday: () => Navigator.of(context).pop(),
)
: _ReflectionHistoryList(
key: const ValueKey('reflection-history'),
reflections: reflections,
onStartToday: () => Navigator.of(context).pop(),
),
),
],
);
},
),
),
);
}
}
class _HistorySwitch extends StatelessWidget {
const _HistorySwitch({required this.value, required this.onChanged});
final _HistoryView value;
final ValueChanged<_HistoryView> onChanged;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLow.withValues(alpha: 0.70),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: scheme.outlineVariant.withValues(alpha: 0.70),
),
),
child: Padding(
padding: const EdgeInsets.all(5),
child: Row(
children: [
Expanded(
child: _HistorySwitchItem(
selected: value == _HistoryView.focus,
icon: Icons.timer_outlined,
label: l10n.focusHistory,
onTap: () => onChanged(_HistoryView.focus),
),
),
const SizedBox(width: 6),
Expanded(
child: _HistorySwitchItem(
selected: value == _HistoryView.notes,
icon: Icons.edit_note_rounded,
label: l10n.notesHistory,
onTap: () => onChanged(_HistoryView.notes),
),
),
],
),
),
);
}
}
class _HistorySwitchItem extends StatelessWidget {
const _HistorySwitchItem({
required this.selected,
required this.icon,
required this.label,
required this.onTap,
});
final bool selected;
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color:
selected
? scheme.primary.withValues(alpha: 0.16)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
boxShadow:
selected
? [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.16),
blurRadius: 12,
offset: const Offset(0, 6),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 19,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color:
selected ? scheme.onSurface : scheme.onSurfaceVariant,
fontWeight: selected ? FontWeight.w900 : FontWeight.w700,
),
),
),
],
),
),
),
);
}
}
class _FocusHistoryList extends StatelessWidget {
const _FocusHistoryList({
required this.sessions,
required this.onStartToday,
super.key,
});
final List<FocusSession> sessions;
final VoidCallback onStartToday;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
if (sessions.isEmpty) {
return _EmptyHistory(
title: l10n.emptyFocusHistoryTitle,
body: l10n.emptyFocusHistoryBody,
tone: SeedIllustrationTone.archive,
onStartToday: onStartToday,
);
}
return Column(
children: [
for (var index = 0; index < sessions.length; index++) ...[
if (index > 0) const SizedBox(height: 8),
_HistoryTimelineTile(
leading: Icons.timer_outlined,
title: l10n.focusMinutes(sessions[index].minutes),
subtitle: localizedReadableDate(
context,
sessions[index].completedAt,
),
trailing: localizedTime(context, sessions[index].completedAt),
),
],
],
);
}
}
class _ReflectionHistoryList extends StatelessWidget {
const _ReflectionHistoryList({
required this.reflections,
required this.onStartToday,
super.key,
});
final List<ReflectionEntry> reflections;
final VoidCallback onStartToday;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
if (reflections.isEmpty) {
return _EmptyHistory(
title: l10n.emptyNotesHistoryTitle,
body: l10n.emptyNotesHistoryBody,
tone: SeedIllustrationTone.data,
onStartToday: onStartToday,
);
}
return Column(
children: [
for (var index = 0; index < reflections.length; index++) ...[
if (index > 0) const SizedBox(height: 8),
_HistoryTimelineTile(
leading: reflections[index].mood.icon,
title: reflections[index].note,
subtitle: reflections[index].dateKey,
),
],
],
);
}
}
class _EmptyHistory extends StatelessWidget {
const _EmptyHistory({
required this.title,
required this.body,
required this.tone,
required this.onStartToday,
});
final String title;
final String body;
final SeedIllustrationTone tone;
final VoidCallback onStartToday;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final minHeight = MediaQuery.sizeOf(context).height * 0.62;
return SizedBox(
height: minHeight.clamp(430, 620).toDouble(),
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.topCenter,
radius: 1.1,
colors: [
scheme.primary.withValues(alpha: 0.16),
scheme.surfaceContainerLowest.withValues(alpha: 0.76),
Colors.transparent,
],
),
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 26, 18, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SeedIllustration(size: 142, tone: tone, hero: true),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 310),
child: Text(
body,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 18),
FilledButton.icon(
onPressed: onStartToday,
icon: const Icon(Icons.arrow_back_rounded),
label: Text(context.l10n.navToday),
),
],
),
),
),
),
);
}
}
class _HistoryTimelineTile extends StatelessWidget {
const _HistoryTimelineTile({
required this.leading,
required this.title,
required this.subtitle,
this.trailing,
});
final IconData leading;
final String title;
final String subtitle;
final String? trailing;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return SurfacePanel(
padding: const EdgeInsets.fromLTRB(13, 12, 14, 12),
borderColor: scheme.outlineVariant.withValues(alpha: 0.60),
child: Row(
children: [
DecoratedBox(
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: SizedBox.square(
dimension: 42,
child: Icon(leading, color: scheme.primary, size: 21),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 3),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
if (trailing != null) ...[
const SizedBox(width: 10),
Text(
trailing!,
style: theme.textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
fontWeight: FontWeight.w800,
),
),
],
],
),
);
}
}
@@ -1,238 +0,0 @@
import 'package:flutter/material.dart';
import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart';
import '../../l10n/l10n.dart';
import '../../models/journey_stats.dart';
import '../../models/today_progress.dart';
import '../../widgets/focus_seed_header.dart';
import '../../widgets/seed_illustration.dart';
import '../../widgets/surface_panel.dart';
import '../reflection/reflection_sheet.dart';
import 'history_screen.dart';
import 'widgets/growth_week_strip.dart';
import 'widgets/journey_overview_panel.dart';
import 'widgets/journey_stats_panel.dart';
class JourneyScreen extends StatelessWidget {
const JourneyScreen({required this.controller, super.key});
final FocusSeedController controller;
@override
Widget build(BuildContext context) {
final state = controller.state!;
final l10n = context.l10n;
final now = DateTime.now();
final progress = TodayProgress.fromState(state, todayKey());
final stats = JourneyStats.fromState(state);
final recentSessions = state.sessions.reversed.take(5).toList();
final recentReflections = state.reflections.reversed.take(3).toList();
return Column(
children: [
FocusSeedHeader(
title: l10n.journeyTitle,
subtitle: localizedReadableDate(context, now),
isSaving: controller.isSaving,
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 6, 20, 32),
children: [
JourneyOverviewPanel(
progress: progress,
onReflect: () => showReflectionSheet(context, controller),
),
const SizedBox(height: 12),
JourneyStatsPanel(stats: stats),
const SizedBox(height: 12),
GrowthWeekStrip(state: state),
const SizedBox(height: 12),
_HistoryGateway(
onTap:
() => Navigator.of(context).push(
MaterialPageRoute<void>(
builder:
(context) => HistoryScreen(controller: controller),
),
),
),
if (recentSessions.isNotEmpty) ...[
const SizedBox(height: 18),
_SectionTitle(
title: l10n.recentFocus,
detail: l10n.sessionsTotal(state.sessions.length),
),
const SizedBox(height: 8),
SurfacePanel(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 4,
),
child: Column(
children: [
for (
var index = 0;
index < recentSessions.length;
index++
) ...[
if (index > 0) const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.timer_outlined),
title: Text(
l10n.focusMinutes(recentSessions[index].minutes),
),
subtitle: Text(
localizedReadableDate(
context,
recentSessions[index].completedAt,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
localizedTime(
context,
recentSessions[index].completedAt,
),
),
),
],
],
),
),
],
if (recentReflections.isNotEmpty) ...[
const SizedBox(height: 18),
_SectionTitle(
title: l10n.recentReflections,
detail: l10n.recentReflectionsDetail,
),
const SizedBox(height: 8),
SurfacePanel(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 4,
),
child: Column(
children: [
for (
var index = 0;
index < recentReflections.length;
index++
) ...[
if (index > 0) const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(recentReflections[index].mood.icon),
title: Text(
recentReflections[index].note,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(recentReflections[index].dateKey),
),
],
],
),
),
],
],
),
),
],
);
}
}
class _HistoryGateway extends StatelessWidget {
const _HistoryGateway({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return SurfacePanel(
elevated: true,
padding: EdgeInsets.zero,
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 14, 14, 14),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
scheme.secondary.withValues(alpha: 0.10),
scheme.surfaceContainerLowest,
],
),
),
child: Row(
children: [
const SeedIllustration(
size: 68,
tone: SeedIllustrationTone.archive,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.viewHistory,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 3),
Text(
l10n.viewHistorySubtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 10),
Icon(
Icons.arrow_forward_ios_rounded,
size: 17,
color: scheme.primary,
),
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title, required this.detail});
final String title;
final String detail;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
Text(
detail,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
);
}
}
@@ -1,99 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/date_keys.dart';
import '../../../l10n/l10n.dart';
import '../../../models/app_state.dart';
import '../../../models/growth_stage.dart';
import '../../../widgets/surface_panel.dart';
class GrowthWeekStrip extends StatelessWidget {
const GrowthWeekStrip({required this.state, super.key});
final AppState state;
@override
Widget build(BuildContext context) {
final days = lastSevenDateKeys();
final l10n = context.l10n;
return SurfacePanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.sevenDayGrowth,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
l10n.sevenDayGrowthSubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
Row(
children: [
for (final day in days)
Expanded(
child: _GrowthDay(
day: day,
stage: growthStageFor(state, day),
),
),
],
),
],
),
);
}
}
class _GrowthDay extends StatelessWidget {
const _GrowthDay({required this.day, required this.stage});
final String day;
final GrowthStage stage;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final icon = switch (stage) {
GrowthStage.seed => Icons.circle_outlined,
GrowthStage.sprout => Icons.grass_rounded,
GrowthStage.leaves => Icons.eco_rounded,
GrowthStage.flower => Icons.local_florist_rounded,
};
return Semantics(
label: l10n.growthDaySemantic(
day.substring(5),
localizedGrowthStage(l10n, stage),
),
child: Column(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
stage == GrowthStage.seed
? theme.colorScheme.surfaceContainer
: theme.colorScheme.primary.withValues(alpha: 0.14),
),
child: Icon(
icon,
size: 19,
color:
stage == GrowthStage.seed
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.primary,
),
),
const SizedBox(height: 5),
Text(day.substring(8), style: theme.textTheme.labelSmall),
],
),
);
}
}
@@ -1,272 +0,0 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart';
import '../../../widgets/seed_illustration.dart';
import '../../../widgets/surface_panel.dart';
class JourneyOverviewPanel extends StatelessWidget {
const JourneyOverviewPanel({
required this.progress,
required this.onReflect,
super.key,
});
final TodayProgress progress;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final completed = progress.completedSteps;
final progressValue = completed / 4;
final title = switch (completed) {
0 => l10n.journeyOverviewStartTitle,
4 => l10n.journeyOverviewDoneTitle,
_ => l10n.journeyOverviewActiveTitle,
};
final subtitle = switch (completed) {
0 => l10n.journeyOverviewStartBody,
4 => l10n.journeyOverviewDoneBody,
_ => l10n.journeyOverviewActiveBody,
};
final showReflectAction =
progress.hasIntention && progress.hasFocus && !progress.hasReflection;
return SurfacePanel(
elevated: true,
padding: EdgeInsets.zero,
borderColor: scheme.outlineVariant.withValues(alpha: 0.45),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
scheme.primary.withValues(alpha: 0.20),
scheme.surfaceContainerLowest.withValues(alpha: 0.96),
scheme.secondary.withValues(alpha: 0.14),
],
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final textScale = MediaQuery.textScalerOf(context).scale(14) / 14;
final compact = constraints.maxWidth < 360 || textScale > 1.25;
final textContent = _OverviewText(
title: title,
subtitle: subtitle,
completed: completed,
progressValue: progressValue,
showReflectAction: showReflectAction,
onReflect: onReflect,
);
if (compact) {
return Stack(
children: [
const Positioned(
right: -18,
top: -20,
child: Opacity(
opacity: 0.58,
child: SeedIllustration(size: 128, hero: true),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 17, 106, 18),
child: textContent,
),
],
);
}
return Stack(
children: [
Positioned(
right: -28,
top: -22,
bottom: -18,
child: Opacity(
opacity: 0.96,
child: SeedIllustration(
size: constraints.maxWidth.clamp(146, 184).toDouble(),
hero: true,
),
),
),
Positioned(
right: 92,
bottom: 18,
child: _ProgressBadge(completed: completed),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 17, 168, 18),
child: textContent,
),
],
);
},
),
),
);
}
}
class _OverviewText extends StatelessWidget {
const _OverviewText({
required this.title,
required this.subtitle,
required this.completed,
required this.progressValue,
required this.showReflectAction,
required this.onReflect,
});
final String title;
final String subtitle;
final int completed;
final double progressValue;
final bool showReflectAction;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final scheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.journeyOverviewLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_ProgressSeeds(completed: completed, progressValue: progressValue),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
l10n.journeyOverviewProgress(completed),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge,
),
),
if (showReflectAction)
TextButton.icon(
onPressed: onReflect,
icon: const Icon(Icons.edit_note_rounded, size: 18),
label: Text(l10n.reviewNow),
),
],
),
],
);
}
}
class _ProgressSeeds extends StatelessWidget {
const _ProgressSeeds({required this.completed, required this.progressValue});
final int completed;
final double progressValue;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Semantics(
value: '${(progressValue * 100).round()}%',
child: Row(
children: List.generate(4, (index) {
final filled = index < completed;
return Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
height: 10,
margin: EdgeInsets.only(right: index == 3 ? 0 : 7),
decoration: BoxDecoration(
color:
filled
? scheme.primary
: scheme.surfaceContainerHigh.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(99),
boxShadow:
filled
? [
BoxShadow(
color: scheme.primary.withValues(alpha: 0.28),
blurRadius: 10,
offset: const Offset(0, 4),
),
]
: null,
),
),
);
}),
),
);
}
}
class _ProgressBadge extends StatelessWidget {
const _ProgressBadge({required this.completed});
final int completed;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(99),
border: Border.all(
color: scheme.outlineVariant.withValues(alpha: 0.65),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.10),
blurRadius: 14,
offset: const Offset(0, 7),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 7),
child: Text(
'$completed/4',
style: theme.textTheme.labelLarge?.copyWith(
color: scheme.primary,
fontWeight: FontWeight.w900,
),
),
),
);
}
}
@@ -1,144 +0,0 @@
import 'package:flutter/material.dart';
import '../../../l10n/l10n.dart';
import '../../../models/journey_stats.dart';
import '../../../widgets/surface_panel.dart';
class JourneyStatsPanel extends StatelessWidget {
const JourneyStatsPanel({required this.stats, super.key});
final JourneyStats stats;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return SurfacePanel(
elevated: true,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.journeyStatsTitle,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 10),
LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth > 520 ? 4 : 2;
return GridView.count(
crossAxisCount: columns,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: columns == 4 ? 2.05 : 2.15,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
children: [
_StatItem(
icon: Icons.timer_outlined,
animatedTarget: stats.weekFocusMinutes,
valueBuilder: (value) => value.toString(),
label: l10n.weekFocusLabel,
),
_StatItem(
icon: Icons.local_fire_department_outlined,
animatedTarget: stats.currentStreakDays,
valueBuilder: l10n.daysValue,
label: l10n.streakLabel,
),
_StatItem(
icon: Icons.calendar_month_outlined,
animatedTarget: stats.activeDaysThisWeek,
valueBuilder: l10n.activeDaysValue,
label: l10n.activeDaysLabel,
),
_StatItem(
icon: Icons.check_circle_outline_rounded,
animatedTarget: (stats.habitCompletionRate * 100).round(),
valueBuilder: (value) => '$value%',
label: l10n.habitRateLabel,
),
],
);
},
),
],
),
);
}
}
class _StatItem extends StatelessWidget {
const _StatItem({
required this.icon,
required this.animatedTarget,
required this.valueBuilder,
required this.label,
});
final IconData icon;
final int animatedTarget;
final String Function(int value) valueBuilder;
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 10),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: scheme.primary, size: 19),
),
const SizedBox(width: 9),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TweenAnimationBuilder<double>(
tween: Tween(end: animatedTarget.toDouble()),
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
builder:
(context, animatedValue, _) => Text(
valueBuilder(animatedValue.round()),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
}
@@ -1,181 +0,0 @@
import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart';
import '../../../widgets/surface_panel.dart';
class JourneyTimeline extends StatelessWidget {
const JourneyTimeline({
required this.progress,
required this.onReflect,
super.key,
});
final TodayProgress progress;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final items = [
_TimelineData(
title: l10n.progressIntention,
detail:
progress.hasIntention ? progress.intention : l10n.chooseOneOutcome,
icon: Icons.flag_outlined,
complete: progress.hasIntention,
),
_TimelineData(
title: l10n.progressFocus,
detail:
progress.hasFocus
? l10n.focusedMinutes(progress.focusMinutes)
: l10n.noFocusSessionYet,
icon: Icons.timer_outlined,
complete: progress.hasFocus,
),
_TimelineData(
title: l10n.progressHabits,
detail: l10n.habitsCompleted(
progress.completedHabits,
progress.totalHabits,
),
icon: Icons.eco_outlined,
complete: progress.hasHabits,
),
_TimelineData(
title: l10n.progressReflect,
detail:
progress.hasReflection
? l10n.reflectionCaptured
: l10n.reflectionAction,
icon: Icons.auto_awesome_outlined,
complete: progress.hasReflection,
action: progress.hasReflection ? null : onReflect,
),
_TimelineData(
title: l10n.growthTitle,
detail:
progress.hasReflection ? l10n.growthFlowerOpen : l10n.growthLocked,
icon: Icons.local_florist_outlined,
complete: progress.hasReflection,
),
];
return Column(
children: [
for (var index = 0; index < items.length; index++)
_TimelineItem(data: items[index], isLast: index == items.length - 1),
],
);
}
}
class _TimelineItem extends StatelessWidget {
const _TimelineItem({required this.data, required this.isLast});
final _TimelineData data;
final bool isLast;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
final accent =
data.icon == Icons.auto_awesome_outlined
? visuals.reflection
: theme.colorScheme.primary;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 38,
child: Column(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
data.complete
? accent
: theme.colorScheme.surfaceContainerLowest,
border: Border.all(color: accent),
),
child: Icon(
data.complete ? Icons.check_rounded : data.icon,
size: 16,
color: data.complete ? theme.colorScheme.onPrimary : accent,
),
),
if (!isLast)
Expanded(
child: Container(
width: 2,
color:
data.complete
? accent.withValues(alpha: 0.55)
: theme.colorScheme.outlineVariant,
),
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SurfacePanel(
padding: const EdgeInsets.all(14),
borderColor:
data.action == null ? null : accent.withValues(alpha: 0.5),
onTap: data.action,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(data.title, style: theme.textTheme.labelLarge),
const SizedBox(height: 3),
Text(
data.detail,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (data.action != null)
Icon(Icons.arrow_forward_rounded, color: accent),
],
),
),
),
),
],
),
);
}
}
class _TimelineData {
const _TimelineData({
required this.title,
required this.detail,
required this.icon,
required this.complete,
this.action,
});
final String title;
final String detail;
final IconData icon;
final bool complete;
final VoidCallback? action;
}
File diff suppressed because it is too large Load Diff
+104 -46
View File
@@ -1,22 +1,34 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../app/app_theme.dart';
import '../../application/focus_seed_controller.dart'; import '../../application/focus_seed_controller.dart';
import '../../core/date_keys.dart'; import '../../core/date_keys.dart';
import '../../core/iterable_extensions.dart'; import '../../core/iterable_extensions.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import '../../models/mood.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( Future<void> showReflectionSheet(
BuildContext context, BuildContext context,
FocusSeedController controller, FocusSeedController controller, {
) { String? dateKey,
String contextTitle = '',
}) {
final state = controller.state; final state = controller.state;
if (state == null) { if (state == null) {
return Future.value(); return Future.value();
} }
final existing = final targetDateKey = dateKey ?? todayKey();
final title = contextTitle.trim();
final existingReflection =
state.reflections state.reflections
.where((entry) => entry.dateKey == todayKey()) .where((entry) => entry.dateKey == targetDateKey)
.firstOrNull;
final legacyProgress =
state.progressNotes
.where((entry) => entry.dateKey == targetDateKey)
.firstOrNull; .firstOrNull;
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
@@ -24,9 +36,15 @@ Future<void> showReflectionSheet(
useSafeArea: true, useSafeArea: true,
builder: builder:
(context) => _ReflectionComposer( (context) => _ReflectionComposer(
initialNote: existing?.note ?? '', initialNote: existingReflection?.note ?? legacyProgress?.note ?? '',
initialMood: existing?.mood ?? Mood.clear, contextTitle: title,
onSave: controller.saveReflection, dateKey: targetDateKey,
onSave:
(note) => controller.saveReflectionForDate(
targetDateKey,
note,
existingReflection?.mood ?? Mood.clear,
),
), ),
); );
} }
@@ -34,13 +52,15 @@ Future<void> showReflectionSheet(
class _ReflectionComposer extends StatefulWidget { class _ReflectionComposer extends StatefulWidget {
const _ReflectionComposer({ const _ReflectionComposer({
required this.initialNote, required this.initialNote,
required this.initialMood, required this.contextTitle,
required this.dateKey,
required this.onSave, required this.onSave,
}); });
final String initialNote; final String initialNote;
final Mood initialMood; final String contextTitle;
final Future<bool> Function(String note, Mood mood) onSave; final String dateKey;
final Future<bool> Function(String note) onSave;
@override @override
State<_ReflectionComposer> createState() => _ReflectionComposerState(); State<_ReflectionComposer> createState() => _ReflectionComposerState();
@@ -48,7 +68,6 @@ class _ReflectionComposer extends StatefulWidget {
class _ReflectionComposerState extends State<_ReflectionComposer> { class _ReflectionComposerState extends State<_ReflectionComposer> {
late final TextEditingController _controller; late final TextEditingController _controller;
late Mood _mood;
bool _saving = false; bool _saving = false;
String? _errorText; String? _errorText;
@@ -56,7 +75,6 @@ class _ReflectionComposerState extends State<_ReflectionComposer> {
void initState() { void initState() {
super.initState(); super.initState();
_controller = TextEditingController(text: widget.initialNote); _controller = TextEditingController(text: widget.initialNote);
_mood = widget.initialMood;
} }
@override @override
@@ -75,7 +93,7 @@ class _ReflectionComposerState extends State<_ReflectionComposer> {
_saving = true; _saving = true;
_errorText = null; _errorText = null;
}); });
final saved = await widget.onSave(_controller.text, _mood); final saved = await widget.onSave(_controller.text);
if (!mounted) { if (!mounted) {
return; return;
} }
@@ -92,73 +110,113 @@ class _ReflectionComposerState extends State<_ReflectionComposer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
return Padding( final theme = Theme.of(context);
padding: EdgeInsets.fromLTRB( final scheme = theme.colorScheme;
20, final visuals = FocusSeedVisuals.of(context);
4, return AppBottomSheetScaffold(
20, title: l10n.reflectionSheetTitle,
MediaQuery.viewInsetsOf(context).bottom + 24, 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: SingleChildScrollView( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.flag_outlined, color: visuals.action, size: 19),
const SizedBox(width: 10),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
l10n.reflectionSheetTitle, l10n.todayIntentionLabel,
style: Theme.of(context).textTheme.titleLarge, style: theme.textTheme.labelSmall?.copyWith(
color: visuals.action,
fontWeight: FontWeight.w900,
letterSpacing: 0,
), ),
const SizedBox(height: 4), ),
const SizedBox(height: 2),
Text( Text(
l10n.reflectionSheetSubtitle, widget.contextTitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( maxLines: 2,
color: Theme.of(context).colorScheme.onSurfaceVariant, overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w900,
height: 1.18,
), ),
), ),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final mood in Mood.values)
ChoiceChip(
avatar: Icon(mood.icon, size: 17),
label: Text(localizedMood(l10n, mood)),
selected: _mood == mood,
onSelected: (_) => setState(() => _mood = mood),
),
], ],
), ),
const SizedBox(height: 14), ),
TextField( ],
),
),
const SizedBox(height: 12),
],
SurfacePanel(
radius: 18,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
tone: SurfacePanelTone.paper,
child: TextField(
controller: _controller, controller: _controller,
autofocus: widget.initialNote.isEmpty, autofocus: widget.initialNote.isEmpty,
minLines: 4, minLines: 4,
maxLines: 7, maxLines: 7,
maxLength: 280, maxLength: 280,
scrollPadding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom + 32,
),
textCapitalization: TextCapitalization.sentences, textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration( decoration: InputDecoration(
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: l10n.reflectionHint, hintText: l10n.reflectionHint,
errorText: _errorText, errorText: _errorText,
), ),
), ),
const SizedBox(height: 10), ),
const SizedBox(height: 12),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: _saving ? null : _save, onPressed: _saving ? null : _save,
icon: icon:
_saving _saving
? const SizedBox.square( ? AppBusyIndicator(semanticLabel: l10n.savingLabel)
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome_outlined), : const Icon(Icons.auto_awesome_outlined),
label: Text(_saving ? l10n.savingLabel : l10n.saveReflection), 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,
),
),
), ),
), ),
], ],
), ),
),
); );
} }
} }
+270 -25
View File
@@ -1,15 +1,22 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../app/app_theme.dart';
import '../../application/focus_seed_controller.dart'; import '../../application/focus_seed_controller.dart';
import '../../application/focus_timer_controller.dart'; import '../../application/focus_timer_controller.dart';
import '../../data/focus_seed_store.dart'; import '../../data/focus_seed_store.dart';
import '../../data/focus_timer_session_store.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import '../../models/appearance_preference.dart'; import '../../models/appearance_preference.dart';
import '../../models/language_preference.dart'; import '../../models/language_preference.dart';
import '../../widgets/loading_view.dart'; import '../../widgets/loading_view.dart';
import '../journey/journey_screen.dart'; import '../../widgets/tactile_canvas.dart';
import '../focus/focus_screen.dart';
import '../me/me_screen.dart'; import '../me/me_screen.dart';
import '../onboarding/landing_setup_screen.dart';
import '../records/records_screen.dart';
import '../today/today_screen.dart'; import '../today/today_screen.dart';
class FocusSeedHome extends StatefulWidget { class FocusSeedHome extends StatefulWidget {
@@ -20,6 +27,8 @@ class FocusSeedHome extends StatefulWidget {
required this.showLanguageTools, required this.showLanguageTools,
required this.onLanguageChanged, required this.onLanguageChanged,
this.focusSeedStore, this.focusSeedStore,
this.showLandingSetupInitially = false,
this.onLandingSetupComplete,
super.key, super.key,
}); });
@@ -29,6 +38,8 @@ class FocusSeedHome extends StatefulWidget {
final bool showLanguageTools; final bool showLanguageTools;
final ValueChanged<LanguagePreference> onLanguageChanged; final ValueChanged<LanguagePreference> onLanguageChanged;
final FocusSeedStore? focusSeedStore; final FocusSeedStore? focusSeedStore;
final bool showLandingSetupInitially;
final VoidCallback? onLandingSetupComplete;
@override @override
State<FocusSeedHome> createState() => _FocusSeedHomeState(); State<FocusSeedHome> createState() => _FocusSeedHomeState();
@@ -38,6 +49,13 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
FocusSeedController? _controller; FocusSeedController? _controller;
FocusTimerController? _timerController; FocusTimerController? _timerController;
int _tabIndex = 0; int _tabIndex = 0;
late bool _landingSetupVisible;
@override
void initState() {
super.initState();
_landingSetupVisible = widget.showLandingSetupInitially;
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -50,14 +68,41 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
defaultHabitTitles: localizedStarterHabits(context.l10n), defaultHabitTitles: localizedStarterHabits(context.l10n),
); );
_timerController = FocusTimerController( _timerController = FocusTimerController(
onCompleted: (minutes) async { sessionStore: FocusTimerSessionStore(),
final saved = await _controller!.completeFocusSession(minutes); onCompleted: (minutes, actualSeconds) async {
final saved = await _controller!.completeFocusSession(
minutes,
actualSeconds: actualSeconds,
);
if (saved) { if (saved) {
await HapticFeedback.mediumImpact(); await HapticFeedback.mediumImpact();
} }
return saved;
}, },
); );
_controller!.load(); unawaited(_loadState());
}
Future<void> _loadState() async {
final controller = _controller;
final timerController = _timerController;
if (controller == null || timerController == null) {
return;
}
await controller.load();
final defaultMinutes = controller.state?.defaultFocusMinutes;
if (!mounted || defaultMinutes == null) {
return;
}
final restored = await timerController.restoreSavedSession();
if (!mounted) {
return;
}
if (restored) {
setState(() => _tabIndex = 1);
return;
}
timerController.selectMinutes(defaultMinutes);
} }
@override @override
@@ -109,6 +154,24 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
} }
final theme = Theme.of(context); final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark; 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>( return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle( value: SystemUiOverlayStyle(
statusBarColor: Colors.transparent, statusBarColor: Colors.transparent,
@@ -121,54 +184,74 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
isDark ? Brightness.light : Brightness.dark, isDark ? Brightness.light : Brightness.dark,
), ),
child: Scaffold( child: Scaffold(
body: SafeArea( body: TactileCanvas(
child:
_landingSetupVisible
? LandingSetupScreen(
controller: controller,
timerController: timerController,
onDone: completeLandingSetup,
onStartFocus:
() => completeLandingSetup(openFocus: true),
)
: SafeArea(
child: IndexedStack( child: IndexedStack(
index: _tabIndex, index: _tabIndex,
children: [ children: [
TodayScreen( TodayScreen(
controller: controller, controller: controller,
timerController: timerController, timerController: timerController,
onOpenSettings: openMe,
onOpenFocus:
() => setState(() => _tabIndex = 1),
),
FocusScreen(
controller: controller,
timerController: timerController,
onBackToday:
() => setState(() => _tabIndex = 0),
),
RecordsScreen(
controller: controller,
onStartToday:
() => setState(() => _tabIndex = 0),
), ),
JourneyScreen(controller: controller),
MeScreen( MeScreen(
controller: controller, controller: controller,
appearance: widget.appearance, appearance: widget.appearance,
onAppearanceChanged: widget.onAppearanceChanged, onAppearanceChanged:
widget.onAppearanceChanged,
language: widget.language, language: widget.language,
showLanguageTools: widget.showLanguageTools, showLanguageTools: widget.showLanguageTools,
onLanguageChanged: widget.onLanguageChanged, onLanguageChanged: widget.onLanguageChanged,
onDefaultFocusMinutesChanged: (minutes) {
if (!timerController.isRunning &&
!timerController.isPaused) {
timerController.selectMinutes(minutes);
}
},
embedded: true,
), ),
], ],
), ),
), ),
bottomNavigationBar: NavigationBar( ),
bottomNavigationBar:
_landingSetupVisible || hideBottomNavigation
? null
: _FocusSeedNavigationBar(
selectedIndex: _tabIndex, selectedIndex: _tabIndex,
onDestinationSelected: (index) { onDestinationSelected: (index) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
setState(() => _tabIndex = index); setState(() => _tabIndex = index);
}, },
destinations: [
NavigationDestination(
icon: const Icon(Icons.eco_outlined),
selectedIcon: const Icon(Icons.eco_rounded),
label: context.l10n.navToday,
),
NavigationDestination(
icon: const Icon(Icons.landscape_outlined),
selectedIcon: const Icon(Icons.landscape_rounded),
label: context.l10n.navJourney,
),
NavigationDestination(
icon: const Icon(Icons.person_outline_rounded),
selectedIcon: const Icon(Icons.person_rounded),
label: context.l10n.navMe,
),
],
), ),
), ),
); );
}, },
); );
},
);
} }
@override @override
@@ -179,6 +262,168 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
} }
} }
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,
),
_FocusSeedNavigationDestination(
key: 'focus',
icon: Icons.timer_outlined,
label: l10n.progressFocus,
),
_FocusSeedNavigationDestination(
key: 'records',
icon: Icons.library_books_outlined,
label: l10n.navRecords,
),
_FocusSeedNavigationDestination(
key: 'me',
icon: Icons.person_outline_rounded,
label: l10n.navMe,
),
];
return SafeArea(
top: false,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest.withValues(alpha: 0.98),
border: Border(
top: BorderSide(
color: scheme.outlineVariant.withValues(alpha: 0.58),
),
),
),
child: Material(
color: Colors.transparent,
child: SizedBox(
height: 72,
child: Row(
children: [
for (var index = 0; index < destinations.length; index++)
Expanded(
child: _FocusSeedNavigationItem(
destination: destinations[index],
selected: index == selectedIndex,
onTap: () => onDestinationSelected(index),
),
),
],
),
),
),
),
);
}
}
class _FocusSeedNavigationDestination {
const _FocusSeedNavigationDestination({
required this.key,
required this.icon,
required this.label,
});
final String key;
final IconData icon;
final String label;
}
class _FocusSeedNavigationItem extends StatelessWidget {
const _FocusSeedNavigationItem({
required this.destination,
required this.selected,
required this.onTap,
});
final _FocusSeedNavigationDestination destination;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
final isDark = theme.brightness == Brightness.dark;
final selectedColor = visuals.focus;
final inactiveColor =
isDark ? theme.colorScheme.onSurfaceVariant : const Color(0xFF4B5563);
final foregroundColor = selected ? selectedColor : inactiveColor;
return Semantics(
button: true,
selected: selected,
label: destination.label,
child: InkWell(
key: ValueKey('focus-seed-nav-${destination.key}'),
onTap: onTap,
child: SizedBox.expand(
child: Stack(
children: [
Align(
alignment: Alignment.topCenter,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: selected ? 34 : 0,
height: 3,
decoration: BoxDecoration(
color: selectedColor,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(3),
bottomRight: Radius.circular(3),
),
),
),
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(destination.icon, size: 23, color: foregroundColor),
const SizedBox(height: 5),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
style: theme.textTheme.labelMedium!.copyWith(
color: foregroundColor,
fontSize: 12,
fontWeight:
selected ? FontWeight.w800 : FontWeight.w600,
letterSpacing: 0,
height: 1.1,
),
child: Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
),
);
}
}
String? _messageForError(AppLocalizations l10n, FocusSeedError? error) { String? _messageForError(AppLocalizations l10n, FocusSeedError? error) {
return switch (error) { return switch (error) {
FocusSeedError.openFailed => l10n.openFailed, FocusSeedError.openFailed => l10n.openFailed,
+643 -43
View File
@@ -1,56 +1,67 @@
import 'package:flutter/material.dart'; 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_seed_controller.dart';
import '../../application/focus_timer_controller.dart'; import '../../application/focus_timer_controller.dart';
import '../../core/date_keys.dart'; import '../../core/date_keys.dart';
import '../../core/iterable_extensions.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import '../../models/habit.dart';
import '../../models/reflection_entry.dart';
import '../../models/today_progress.dart'; import '../../models/today_progress.dart';
import '../../widgets/dialogs.dart'; import '../../widgets/dialogs.dart';
import '../../widgets/focus_seed_header.dart'; import '../../widgets/experience_motion.dart';
import '../../widgets/surface_panel.dart'; import '../../widgets/workbench_ui.dart';
import '../habits/habit_manager_sheet.dart'; import '../habits/habit_manager_sheet.dart';
import '../reflection/reflection_sheet.dart'; import '../reflection/reflection_sheet.dart';
import 'widgets/daily_progress_rail.dart';
import 'widgets/focus_session_card.dart';
import 'widgets/habit_section.dart';
import 'widgets/intention_card.dart';
import 'widgets/reflection_prompt.dart';
class TodayScreen extends StatelessWidget { class TodayScreen extends StatelessWidget {
const TodayScreen({ const TodayScreen({
required this.controller, required this.controller,
required this.timerController, required this.timerController,
required this.onOpenSettings,
required this.onOpenFocus,
super.key, super.key,
}); });
final FocusSeedController controller; final FocusSeedController controller;
final FocusTimerController timerController; final FocusTimerController timerController;
final VoidCallback onOpenSettings;
final VoidCallback onOpenFocus;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = controller.state!; final state = controller.state!;
final l10n = context.l10n; final l10n = context.l10n;
final now = DateTime.now();
final today = todayKey(); final today = todayKey();
final progress = TodayProgress.fromState(state, today); final progress = TodayProgress.fromState(state, today);
final reflection = final reflection = _reflectionForToday(state.reflections, today);
state.reflections.where((entry) => entry.dateKey == today).firstOrNull; final activeSession = timerController.isRunning || timerController.isPaused;
void editIntention() { void startFocus() {
showIntentionDialog( unawaited(HapticFeedback.selectionClick());
timerController.start();
onOpenFocus();
}
void openReflection() {
showReflectionSheet(
context, context,
controller.setTodayAnchor, controller,
initialValue: progress.intention, dateKey: today,
contextTitle: progress.intention,
); );
} }
return Column( return Column(
children: [ children: [
FocusSeedHeader( WorkbenchTopBar(
title: localizedGreeting(l10n, now), title: l10n.brandName,
subtitle: localizedReadableDate(context, now), trailingIcon: Icons.lock_outline_rounded,
isSaving: controller.isSaving, trailingTooltip: l10n.privacyTitle,
onTrailingPressed: () => showPrivacySheet(context),
), ),
Expanded( Expanded(
child: ListView( child: ListView(
@@ -62,37 +73,46 @@ class TodayScreen extends StatelessWidget {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
], ],
IntentionCard( StaggeredReveal(
intention: progress.intention, child: _TodayCommandPanel(
onEdit: editIntention, progress: progress,
timerController: timerController,
activeSession: activeSession,
onEditFocus:
() => showIntentionDialog(
context,
controller.setTodayAnchor,
initialValue: progress.intention,
),
onStartFocus: startFocus,
onOpenFocus: onOpenFocus,
onReflect: openReflection,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FocusSessionCard( StaggeredReveal(
controller: timerController, order: 1,
growthStage: progress.growthStage, child: _HabitWorkbenchSection(
hasIntention: progress.hasIntention,
onSetIntention: editIntention,
onReflect: () => showReflectionSheet(context, controller),
),
const SizedBox(height: 12),
SurfacePanel(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
child: DailyProgressRail(progress: progress),
),
const SizedBox(height: 12),
HabitSection(
habits: state.habits, habits: state.habits,
dateKey: today, dateKey: today,
onToggle: controller.toggleHabit, onToggle: (habit) => unawaited(controller.toggleHabit(habit)),
onAdd: () => showHabitDialog(context, controller.addHabit), onAdd: () => showHabitDialog(context, controller.addHabit),
onManage: () => showHabitManagerSheet(context, controller), onManage: () => showHabitManagerSheet(context, controller),
), ),
const SizedBox(height: 12),
ReflectionPrompt(
reflection: reflection,
onTap: () => showReflectionSheet(context, controller),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
StaggeredReveal(
order: 2,
child: _ReflectionWorkbenchSection(
reflection: reflection,
onTap: openReflection,
),
),
const SizedBox(height: 12),
StaggeredReveal(
order: 3,
child: _TodayLedgerStrip(progress: progress),
),
], ],
), ),
), ),
@@ -108,6 +128,568 @@ String _messageForError(AppLocalizations l10n, FocusSeedError error) {
}; };
} }
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 { class _InlineError extends StatelessWidget {
const _InlineError({required this.message}); const _InlineError({required this.message});
@@ -122,7 +704,7 @@ class _InlineError extends StatelessWidget {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: scheme.errorContainer, color: scheme.errorContainer,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(kWorkbenchRadius),
), ),
child: Row( child: Row(
children: [ children: [
@@ -140,3 +722,21 @@ class _InlineError extends StatelessWidget {
); );
} }
} }
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,
),
);
}
}
@@ -1,7 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart'; import '../../../l10n/l10n.dart';
import '../../../models/habit.dart'; import '../../../models/habit.dart';
import '../../../widgets/app_motion.dart';
import '../../../widgets/experience_motion.dart';
import '../../../widgets/pressable_scale.dart';
class CompactHabitRow extends StatelessWidget { class CompactHabitRow extends StatelessWidget {
const CompactHabitRow({ const CompactHabitRow({
@@ -28,52 +33,44 @@ class CompactHabitRow extends StatelessWidget {
button: true, button: true,
checked: complete, checked: complete,
label: habit.title, label: habit.title,
child: PressableScale(
child: InkWell( child: InkWell(
onTap: onToggle, onTap: () {
child: Padding( HapticFeedback.selectionClick();
padding: const EdgeInsets.symmetric(vertical: 10), 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( child: Row(
children: [ children: [
AnimatedContainer( _HabitCheckControl(complete: complete),
duration: const Duration(milliseconds: 180), const SizedBox(width: 12),
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
complete ? theme.colorScheme.primary : Colors.transparent,
border: Border.all(
color:
complete
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: Icon(
complete ? Icons.eco_rounded : Icons.circle_outlined,
key: ValueKey(complete),
size: 16,
color:
complete
? theme.colorScheme.onPrimary
: Colors.transparent,
),
),
),
const SizedBox(width: 11),
Expanded( 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( child: Text(
habit.title, habit.title,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge?.copyWith(
color:
complete
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface,
decoration: complete ? TextDecoration.lineThrough : null,
), ),
), ),
), ),
@@ -89,7 +86,10 @@ class CompactHabitRow extends StatelessWidget {
}, },
itemBuilder: itemBuilder:
(context) => [ (context) => [
PopupMenuItem(value: 'edit', child: Text(l10n.rename)), PopupMenuItem(
value: 'edit',
child: Text(l10n.rename),
),
PopupMenuItem( PopupMenuItem(
value: 'delete', value: 'delete',
child: Text(l10n.delete), child: Text(l10n.delete),
@@ -100,6 +100,55 @@ class CompactHabitRow extends StatelessWidget {
), ),
), ),
), ),
),
);
}
}
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,
),
),
),
),
); );
} }
} }
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart'; import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart'; import '../../../models/today_progress.dart';
import '../../../widgets/app_motion.dart';
class DailyProgressRail extends StatelessWidget { class DailyProgressRail extends StatelessWidget {
const DailyProgressRail({required this.progress, super.key}); const DailyProgressRail({required this.progress, super.key});
@@ -27,16 +29,10 @@ class DailyProgressRail extends StatelessWidget {
icon: Icons.timer_outlined, icon: Icons.timer_outlined,
complete: progress.hasFocus, complete: progress.hasFocus,
), ),
_ProgressItem(
label: l10n.progressHabits,
detail: '${progress.completedHabits}/${progress.totalHabits}',
icon: Icons.eco_outlined,
complete: progress.hasHabits,
),
_ProgressItem( _ProgressItem(
label: l10n.progressReflect, label: l10n.progressReflect,
detail: progress.hasReflection ? l10n.done : l10n.later, detail: progress.hasReflection ? l10n.done : l10n.later,
icon: Icons.auto_awesome_outlined, icon: Icons.edit_note_rounded,
complete: progress.hasReflection, complete: progress.hasReflection,
), ),
]; ];
@@ -60,66 +56,87 @@ class _HorizontalProgress extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(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( return Semantics(
label: context.l10n.dailyStepsComplete( label: context.l10n.dailyStepsComplete(
items.where((item) => item.complete).length, items.where((item) => item.complete).length,
), ),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var index = 0; index < items.length; index++)
Expanded(
child: Column( child: Column(
children: [ children: [
SizedBox( SizedBox(
height: 34, height: 32,
child: Stack( child: LayoutBuilder(
builder: (context, constraints) {
final inset = constraints.maxWidth / (items.length * 2);
return Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
if (index > 0) Positioned(
Align( 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, alignment: Alignment.centerLeft,
child: Container( child: FractionallySizedBox(
width: double.infinity, widthFactor: progress.clamp(0.0, 1.0),
height: 2, child: _ProgressTrack(
color: color: visuals.success,
items[index - 1].complete glow: visuals.success.withValues(alpha: 0.20),
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
), ),
), ),
if (index < items.length - 1)
Align(
alignment: Alignment.centerRight,
child: Container(
width: double.infinity,
height: 2,
color:
items[index].complete
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
), ),
), ),
_ProgressDot(item: items[index]), Positioned.fill(
child: Row(
children: [
for (final item in items)
Expanded(
child: Center(child: _ProgressDot(item: item)),
),
], ],
), ),
), ),
const SizedBox(height: 5), ],
);
},
),
),
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final item in items)
Expanded(
child: Column(
children: [
Text( Text(
items[index].label, item.label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(height: 1),
Text( Text(
items[index].detail, item.detail,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
fontSize: 10, fontSize: 10.5,
), ),
), ),
], ],
@@ -127,6 +144,8 @@ class _HorizontalProgress extends StatelessWidget {
), ),
], ],
), ),
],
),
); );
} }
} }
@@ -161,6 +180,28 @@ class _VerticalProgress extends StatelessWidget {
} }
} }
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 { class _ProgressDot extends StatelessWidget {
const _ProgressDot({required this.item}); const _ProgressDot({required this.item});
@@ -169,29 +210,51 @@ class _ProgressDot extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Container( final scheme = theme.colorScheme;
width: 32, final visuals = FocusSeedVisuals.of(context);
height: 32, 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( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: color: item.complete ? visuals.success : scheme.surfaceContainerLow,
item.complete
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerLowest,
border: Border.all( border: Border.all(
color: color: item.complete ? visuals.success : scheme.outlineVariant,
),
boxShadow:
item.complete item.complete
? theme.colorScheme.primary ? [
: theme.colorScheme.outline, 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( child: Icon(
item.complete ? Icons.check_rounded : item.icon, item.complete ? Icons.check_rounded : item.icon,
size: 17, key: ValueKey('${item.label}-${item.complete}'),
color: size: 15,
item.complete color: item.complete ? scheme.onPrimary : scheme.onSurfaceVariant,
? theme.colorScheme.onPrimary ),
: theme.colorScheme.onSurfaceVariant, ),
), ),
); );
} }
@@ -1,266 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../application/focus_timer_controller.dart';
import '../../../core/date_keys.dart';
import '../../../l10n/l10n.dart';
import '../../../models/growth_stage.dart';
import '../../../widgets/growth_object.dart';
import '../../../widgets/surface_panel.dart';
class FocusSessionCard extends StatelessWidget {
const FocusSessionCard({
required this.controller,
required this.growthStage,
required this.hasIntention,
required this.onSetIntention,
required this.onReflect,
super.key,
});
final FocusTimerController controller;
final GrowthStage growthStage;
final bool hasIntention;
final VoidCallback onSetIntention;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
return SurfacePanel(
elevated: true,
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.focusLabel,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
LayoutBuilder(
builder: (context, constraints) {
final textScale =
MediaQuery.textScalerOf(context).scale(16) / 16;
final stack = constraints.maxWidth < 260 || textScale > 1.6;
final details = _TimerDetails(controller: controller);
final plant = GrowthObject(
stage: growthStage,
size: stack ? 116 : 122,
);
if (stack) {
return Column(
children: [plant, const SizedBox(height: 4), details],
);
}
return Row(
children: [
Expanded(child: details),
const SizedBox(width: 8),
plant,
],
);
},
),
const SizedBox(height: 14),
if (!controller.isRunning && !controller.isCompleted)
SizedBox(
width: double.infinity,
child: SegmentedButton<int>(
segments: [
for (final minutes in FocusTimerController.presets)
ButtonSegment(value: minutes, label: Text('$minutes')),
],
selected: {controller.selectedMinutes},
onSelectionChanged: (selection) {
HapticFeedback.selectionClick();
controller.selectMinutes(selection.first);
},
),
),
if (controller.isRunning || controller.isPaused) ...[
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: controller.progress,
minHeight: 7,
),
),
],
const SizedBox(height: 14),
_TimerActions(
controller: controller,
hasIntention: hasIntention,
onSetIntention: onSetIntention,
onReflect: onReflect,
),
],
),
);
},
);
}
}
class _TimerDetails extends StatelessWidget {
const _TimerDetails({required this.controller});
final FocusTimerController controller;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
final status = switch (controller.status) {
FocusTimerStatus.idle => l10n.timerReady,
FocusTimerStatus.running => l10n.timerRunning,
FocusTimerStatus.paused => l10n.timerPaused,
FocusTimerStatus.completed => l10n.timerCompleted,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: Text(
formatSeconds(controller.remainingSeconds),
key: ValueKey(controller.remainingSeconds),
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 36,
fontWeight: FontWeight.w600,
height: 1,
),
),
),
const SizedBox(height: 8),
Row(
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
controller.isCompleted
? theme.colorScheme.tertiary
: theme.colorScheme.primary,
),
),
const SizedBox(width: 7),
Flexible(
child: Text(
status,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
],
);
}
}
class _TimerActions extends StatelessWidget {
const _TimerActions({
required this.controller,
required this.hasIntention,
required this.onSetIntention,
required this.onReflect,
});
final FocusTimerController controller;
final bool hasIntention;
final VoidCallback onSetIntention;
final VoidCallback onReflect;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
if (controller.isCompleted) {
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: onReflect,
icon: const Icon(Icons.edit_outlined),
label: Text(l10n.reflectOneMinute),
),
),
const SizedBox(width: 8),
IconButton.outlined(
tooltip: l10n.startAnotherSession,
onPressed: controller.reset,
icon: const Icon(Icons.restart_alt_rounded),
),
],
);
}
if (!hasIntention && !controller.isRunning && !controller.isPaused) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onSetIntention,
icon: const Icon(Icons.flag_outlined),
label: Text(l10n.setTodaysIntention),
),
);
}
if (controller.isRunning) {
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: controller.pause,
icon: const Icon(Icons.pause_rounded),
label: Text(l10n.pause),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: controller.completeNow,
icon: const Icon(Icons.stop_rounded),
label: Text(l10n.end),
),
],
);
}
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () {
HapticFeedback.selectionClick();
controller.start();
},
icon: Icon(
controller.isPaused
? Icons.play_arrow_rounded
: Icons.play_circle_outline_rounded,
),
label: Text(
controller.isPaused
? l10n.resume
: l10n.startMinutes(controller.selectedMinutes),
),
),
),
if (controller.isPaused) ...[
const SizedBox(width: 8),
IconButton.outlined(
tooltip: l10n.resetTimer,
onPressed: controller.reset,
icon: const Icon(Icons.restart_alt_rounded),
),
],
],
);
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart'; import '../../../l10n/l10n.dart';
import '../../../models/habit.dart'; import '../../../models/habit.dart';
import '../../../widgets/surface_panel.dart'; import '../../../widgets/surface_panel.dart';
@@ -24,10 +25,16 @@ class HabitSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final visuals = FocusSeedVisuals.of(context);
final completed = final completed =
habits.where((habit) => habit.completedDates.contains(dateKey)).length; habits.where((habit) => habit.completedDates.contains(dateKey)).length;
final completion =
habits.isEmpty ? 0.0 : (completed / habits.length).clamp(0.0, 1.0);
return SurfacePanel( return SurfacePanel(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 8), tone: SurfacePanelTone.paper,
padding: const EdgeInsets.fromLTRB(16, 14, 14, 10),
child: Column( child: Column(
children: [ children: [
Row( Row(
@@ -38,12 +45,14 @@ class HabitSection extends StatelessWidget {
children: [ children: [
Text( Text(
l10n.todaysHabits, l10n.todaysHabits,
style: Theme.of(context).textTheme.titleMedium, style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
), ),
Text( Text(
l10n.completedCount(completed, habits.length), l10n.completedCount(completed, habits.length),
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: scheme.onSurfaceVariant,
), ),
), ),
], ],
@@ -61,16 +70,15 @@ class HabitSection extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 12),
_HabitCompletionTrack(progress: completion),
const SizedBox(height: 6),
if (habits.isEmpty) if (habits.isEmpty)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 20), padding: const EdgeInsets.fromLTRB(4, 16, 4, 20),
child: Row( child: Row(
children: [ children: [
Icon( Icon(Icons.eco_outlined, color: visuals.habit),
Icons.eco_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded(child: Text(l10n.emptyHabitPrompt)), Expanded(child: Text(l10n.emptyHabitPrompt)),
TextButton(onPressed: onAdd, child: Text(l10n.add)), TextButton(onPressed: onAdd, child: Text(l10n.add)),
@@ -79,7 +87,7 @@ class HabitSection extends StatelessWidget {
) )
else else
for (var index = 0; index < habits.take(4).length; index++) ...[ for (var index = 0; index < habits.take(4).length; index++) ...[
if (index > 0) const Divider(), if (index > 0) const Divider(indent: 48),
CompactHabitRow( CompactHabitRow(
habit: habits[index], habit: habits[index],
dateKey: dateKey, dateKey: dateKey,
@@ -87,7 +95,7 @@ class HabitSection extends StatelessWidget {
), ),
], ],
if (habits.length > 4) ...[ if (habits.length > 4) ...[
const Divider(), const Divider(indent: 48),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton.icon( child: TextButton.icon(
@@ -102,3 +110,48 @@ class HabitSection extends StatelessWidget {
); );
} }
} }
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),
),
),
),
],
),
),
);
}
}
@@ -1,40 +1,65 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../app/app_theme.dart';
import '../../../l10n/l10n.dart'; import '../../../l10n/l10n.dart';
import '../../../models/today_progress.dart';
import '../../../widgets/surface_panel.dart'; import '../../../widgets/surface_panel.dart';
class IntentionCard extends StatelessWidget { class IntentionCard extends StatelessWidget {
const IntentionCard({ const IntentionCard({
required this.intention, required this.intention,
required this.progress,
required this.onEdit, required this.onEdit,
super.key, super.key,
}); });
final String intention; final String intention;
final TodayProgress progress;
final VoidCallback onEdit; final VoidCallback onEdit;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final visuals = FocusSeedVisuals.of(context);
final l10n = context.l10n; final l10n = context.l10n;
final isEmpty = intention.isEmpty; final isEmpty = intention.isEmpty;
final body =
isEmpty
? l10n.todayLoopStartBody
: progress.completedSteps >= 3
? l10n.todayLoopDoneBody
: l10n.todayLoopActiveBody;
return SurfacePanel( return SurfacePanel(
elevated: !isEmpty, elevated: !isEmpty,
tone: SurfacePanelTone.paper,
onTap: onEdit, onTap: onEdit,
padding: const EdgeInsets.all(14), padding: const EdgeInsets.fromLTRB(16, 16, 16, 15),
child: Row( 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
width: 42, width: 5,
height: 42, height: 58,
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.11), color: visuals.action,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(99),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.24),
blurRadius: 10,
offset: const Offset(0, 4),
), ),
child: Icon(Icons.flag_outlined, color: theme.colorScheme.primary), ],
), ),
const SizedBox(width: 13), ),
const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -42,8 +67,9 @@ class IntentionCard extends StatelessWidget {
Text( Text(
l10n.todayIntentionLabel, l10n.todayIntentionLabel,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary, color: visuals.action,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w900,
letterSpacing: 0,
), ),
), ),
const SizedBox(height: 5), const SizedBox(height: 5),
@@ -51,8 +77,9 @@ class IntentionCard extends StatelessWidget {
isEmpty ? l10n.chooseOneOutcome : intention, isEmpty ? l10n.chooseOneOutcome : intention,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.headlineSmall?.copyWith(
fontSize: 18, fontWeight: FontWeight.w900,
height: 1.08,
color: color:
isEmpty isEmpty
? theme.colorScheme.onSurfaceVariant ? theme.colorScheme.onSurfaceVariant
@@ -62,14 +89,110 @@ class IntentionCard extends StatelessWidget {
], ],
), ),
), ),
const SizedBox(width: 8), ],
Icon(
isEmpty ? Icons.add_rounded : Icons.edit_outlined,
color: theme.colorScheme.onSurfaceVariant,
size: 21,
), ),
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,
),
),
),
],
),
),
),
);
}
}
@@ -22,7 +22,7 @@ class ReflectionPrompt extends StatelessWidget {
final visuals = FocusSeedVisuals.of(context); final visuals = FocusSeedVisuals.of(context);
final isComplete = reflection != null; final isComplete = reflection != null;
return SurfacePanel( return SurfacePanel(
borderColor: visuals.reflection.withValues(alpha: 0.35), tone: SurfacePanelTone.paper,
color: visuals.reflection.withValues(alpha: 0.06), color: visuals.reflection.withValues(alpha: 0.06),
onTap: onTap, onTap: onTap,
child: Row( child: Row(
+125 -57
View File
@@ -5,11 +5,12 @@
"saving": "Saving", "saving": "Saving",
"local": "Local", "local": "Local",
"navToday": "Today", "navToday": "Today",
"navJourney": "Journey", "navRecords": "Records",
"navMe": "Me", "navMe": "Me",
"tryAgain": "Try again", "tryAgain": "Try again",
"couldNotOpenApp": "Could not open Focus Seed.", "couldNotOpenApp": "Could not open Focus Seed.",
"openFailed": "Your local garden could not be opened.", "loadingLocalData": "Loading local data",
"openFailed": "Your local data could not be opened.",
"saveFailed": "That change could not be saved. Please try again.", "saveFailed": "That change could not be saved. Please try again.",
"goodMorning": "Good morning", "goodMorning": "Good morning",
"goodDay": "Good day", "goodDay": "Good day",
@@ -33,11 +34,23 @@
"timerRunning": "Deep work in progress", "timerRunning": "Deep work in progress",
"timerPaused": "Paused - your place is kept", "timerPaused": "Paused - your place is kept",
"timerCompleted": "Session complete", "timerCompleted": "Session complete",
"reflectOneMinute": "Reflect for 1 min", "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", "startAnotherSession": "Start another session",
"setTodaysIntention": "Set today's focus", "setTodaysIntention": "Set today's focus",
"pause": "Pause", "pause": "Pause",
"end": "End", "end": "End",
"completeSession": "Complete session",
"continueSession": "Continue session",
"resume": "Resume", "resume": "Resume",
"startMinutes": "Start {minutes} min", "startMinutes": "Start {minutes} min",
"@startMinutes": { "@startMinutes": {
@@ -63,7 +76,7 @@
"manageHabits": "Manage habits", "manageHabits": "Manage habits",
"addHabit": "Add habit", "addHabit": "Add habit",
"add": "Add", "add": "Add",
"emptyHabitPrompt": "Add one repeatable habit.", "emptyHabitPrompt": "Keep this optional and small.",
"viewAllHabits": "View all {count}", "viewAllHabits": "View all {count}",
"@viewAllHabits": { "@viewAllHabits": {
"placeholders": { "placeholders": {
@@ -90,7 +103,7 @@
} }
} }
}, },
"dailyStepsComplete": "{completed}/4 daily steps complete", "dailyStepsComplete": "{completed}/3 daily steps complete",
"@dailyStepsComplete": { "@dailyStepsComplete": {
"placeholders": { "placeholders": {
"completed": { "completed": {
@@ -101,16 +114,17 @@
"habitOptions": "Habit options", "habitOptions": "Habit options",
"rename": "Rename", "rename": "Rename",
"delete": "Delete", "delete": "Delete",
"habitsTitle": "Habits", "habitsTitle": "Supporting habits",
"habitsSubtitle": "Keep it small and repeatable.", "habitsSubtitle": "Optional repeatable steps. Today's focus still comes first.",
"emptyHabitsTitle": "Add a small habit", "emptyHabitsTitle": "Add a supporting habit",
"addHabitTitle": "Add habit", "addHabitTitle": "Add habit",
"renameHabitTitle": "Rename habit", "renameHabitTitle": "Rename habit",
"habitHint": "Example: Read 10 pages", "habitHint": "Example: Read 10 pages",
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save", "save": "Save",
"setIntentionTitle": "Set intention", "textEntryRequired": "Write a short line first.",
"editIntentionTitle": "Edit intention", "setIntentionTitle": "Set today's focus",
"editIntentionTitle": "Edit today's focus",
"intentionHint": "What matters most today?", "intentionHint": "What matters most today?",
"deleteHabitTitle": "Delete habit?", "deleteHabitTitle": "Delete habit?",
"deleteHabitBody": "Past completion records for this habit will be removed.", "deleteHabitBody": "Past completion records for this habit will be removed.",
@@ -124,20 +138,22 @@
"deleteAll": "Delete all", "deleteAll": "Delete all",
"privacyTitle": "Privacy & data", "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.", "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 Me at any time.", "privacyBodyTwo": "You can clear today's progress or delete all local data from settings at any time.",
"reflectionEmptyError": "Write one honest sentence.", "reflectionEmptyError": "Write one honest sentence.",
"reflectionSaveError": "Could not save. Please try again.", "reflectionSaveError": "Could not save. Please try again.",
"reflectionSheetTitle": "How did it go?", "reflectionSheetTitle": "Leave one progress note",
"reflectionSheetSubtitle": "Keep what worked. Adjust one thing.", "reflectionSheetSubtitle": "Save what moved forward so next time has a clear start.",
"reflectionHint": "One useful sentence is enough.", "reflectionHint": "What moved forward?",
"reflectionMoodLabel": "Mood is optional",
"savingLabel": "Saving", "savingLabel": "Saving",
"saveReflection": "Save reflection", "saveReflection": "Save note",
"moodCalm": "Calm", "moodCalm": "Calm",
"moodClear": "Clear", "moodClear": "Clear",
"moodProud": "Proud", "moodProud": "Proud",
"moodTired": "Tired", "moodTired": "Tired",
"meTitle": "Your garden", "meTitle": "Me",
"meSubtitle": "Preferences and records stay on this device.", "meSubtitle": "Manage your rhythm, appearance, and records.",
"appSettingsTitle": "App settings",
"appearance": "Theme", "appearance": "Theme",
"system": "System", "system": "System",
"light": "Light", "light": "Light",
@@ -148,14 +164,19 @@
"languageEnglish": "English", "languageEnglish": "English",
"languageChinese": "Chinese", "languageChinese": "Chinese",
"preferencesTitle": "Preferences", "preferencesTitle": "Preferences",
"preferencesDetail": "Make it feel right", "preferencesDetail": "Theme, language, and default focus",
"defaultFocusDuration": "Default focus",
"defaultFocusDurationDetail": "Next session",
"localData": "Local records", "localData": "Local records",
"localDataDetail": "Private on device", "localDataDetail": "Private on device",
"personalGardenTitle": "Your focus garden", "personalGardenTitle": "My accumulation",
"personalGardenSubtitle": "Every small session leaves a trace you can return to.", "personalGardenSubtitle": "Your local action records, progress notes, and app controls stay on this device.",
"focusDataLabel": "Focus", "focusDataLabel": "Focus",
"habitsDataLabel": "Habits", "habitsDataLabel": "Habits",
"notesDataLabel": "Notes", "notesDataLabel": "Progress",
"totalActionTimeLabel": "Action time",
"recordsCountLabel": "Records",
"progressNotesCountLabel": "Progress notes",
"clearToday": "Clear today", "clearToday": "Clear today",
"clearTodaySubtitle": "Reset only today's loop.", "clearTodaySubtitle": "Reset only today's loop.",
"privacySubtitle": "No account or cloud sync.", "privacySubtitle": "No account or cloud sync.",
@@ -163,24 +184,22 @@
"deleteAllLocalDataSubtitle": "Remove this device's Focus Seed records.", "deleteAllLocalDataSubtitle": "Remove this device's Focus Seed records.",
"todayDataCleared": "Today's data cleared", "todayDataCleared": "Today's data cleared",
"localDataCleared": "Local data deleted", "localDataCleared": "Local data deleted",
"journeyTitle": "Progress", "todayLoopLabel": "TODAY'S LOOP",
"journeyOverviewLabel": "TODAY'S LOOP", "todayLoopStartTitle": "Start with one clear focus",
"journeyOverviewStartTitle": "Start with one clear focus", "todayLoopStartBody": "Pick the one result that matters today.",
"journeyOverviewStartBody": "Pick the thing that would make today feel finished.", "todayLoopActiveTitle": "Your day is taking shape",
"journeyOverviewActiveTitle": "Your day is taking shape", "todayLoopActiveBody": "Focus once, then leave one useful note.",
"journeyOverviewActiveBody": "Keep the loop moving: focus, habits, then a short reflection.", "todayLoopDoneTitle": "Progress saved",
"journeyOverviewDoneTitle": "Loop complete", "todayLoopDoneBody": "You moved today forward and left a clear place to continue.",
"journeyOverviewDoneBody": "You left a clean record for future you.", "todayLoopProgress": "{completed}/3 done",
"journeyOverviewProgress": "{completed}/4 done", "@todayLoopProgress": {
"@journeyOverviewProgress": {
"placeholders": { "placeholders": {
"completed": { "completed": {
"type": "int" "type": "int"
} }
} }
}, },
"reviewNow": "Reflect now", "reviewNow": "Write a note",
"journeyStatsTitle": "7-day rhythm",
"weekFocusLabel": "Focus", "weekFocusLabel": "Focus",
"streakLabel": "Streak", "streakLabel": "Streak",
"activeDaysLabel": "Active", "activeDaysLabel": "Active",
@@ -207,9 +226,28 @@
"focusHistory": "Focus", "focusHistory": "Focus",
"notesHistory": "Notes", "notesHistory": "Notes",
"emptyFocusHistoryTitle": "No focus sessions yet", "emptyFocusHistoryTitle": "No focus sessions yet",
"emptyFocusHistoryBody": "Start one session from Today and it will appear here.", "emptyFocusHistoryBody": "Finish one focus session and it will appear here.",
"emptyNotesHistoryTitle": "No notes yet", "emptyNotesHistoryTitle": "No notes yet",
"emptyNotesHistoryBody": "Save a short reflection after focus to make your progress visible.", "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", "recentFocus": "Recent focus",
"sessionsTotal": "{count} sessions total", "sessionsTotal": "{count} sessions total",
"@sessionsTotal": { "@sessionsTotal": {
@@ -250,32 +288,62 @@
} }
}, },
"reflectionCaptured": "Today's lesson is captured", "reflectionCaptured": "Today's lesson is captured",
"reflectionAction": "Close the loop with one sentence", "reflectionAction": "Write one sentence to close today",
"growthTitle": "Growth", "nextActionTitle": "Next action",
"growthFlowerOpen": "Today's flower is open", "nextActionSetFocusTitle": "Write today's focus",
"growthLocked": "Unlocked after reflection", "nextActionSetFocusBody": "One short line is enough. You can adjust it later.",
"sevenDayGrowth": "7-day growth", "nextActionStartFocusTitle": "Start 10 minutes",
"sevenDayGrowthSubtitle": "Small actions become visible over time.", "nextActionStartFocusBody": "Writing the action is optional. Start first.",
"growthSeed": "seed", "nextActionStartActionBody": "Move this forward in one small session, then leave one progress note.",
"growthSprout": "sprout", "nextActionHabitsTitle": "Finish one small habit",
"growthLeaves": "leaves", "nextActionHabitsBody": "Complete one repeatable step while it is still easy.",
"growthFlower": "flower", "nextActionReflectTitle": "Write a quick note",
"growthDaySemantic": "{date}, {stage}", "nextActionReflectBody": "Record what moved forward so the next start is easier.",
"@growthDaySemantic": { "nextActionCompleteTitle": "Today moved forward",
"nextActionCompleteBody": "Your note is saved. Continue only if it still feels useful.",
"nextActionContinueFocus": "Focus {minutes} min again",
"@nextActionContinueFocus": {
"placeholders": { "placeholders": {
"date": { "minutes": {
"type": "Object" "type": "int"
},
"stage": {
"type": "Object"
} }
} }
}, },
"growthSeedSemantic": "A seed waiting for today's intention", "habitTemplatesTitle": "Useful starts",
"growthSproutSemantic": "A sprout growing from today's intention", "habitTemplatesBody": "Add one tiny repeatable action.",
"growthLeavesSemantic": "A young plant growing with today's progress", "habitTemplateStretch": "2 min stretch",
"growthFlowerSemantic": "A flower marking a complete daily cycle", "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", "defaultHabitPlanTask": "Plan one clear task",
"defaultHabitFocus": "Focus for 25 minutes", "defaultHabitFocus": "Focus for 10 minutes",
"defaultHabitReflect": "Write one-line reflection" "defaultHabitReflect": "Write one-line reflection"
} }
+238 -170
View File
@@ -2,19 +2,20 @@
"@@locale": "zh", "@@locale": "zh",
"appTitle": "Focus Seed", "appTitle": "Focus Seed",
"brandName": "Focus Seed", "brandName": "Focus Seed",
"saving": "\u4fdd\u5b58\u4e2d", "saving": "保存中",
"local": "\u672c\u5730", "local": "本地",
"navToday": "\u4eca\u65e5", "navToday": "今日",
"navJourney": "\u8fdb\u5c55", "navRecords": "记录",
"navMe": "\u6211\u7684", "navMe": "我的",
"tryAgain": "\u91cd\u8bd5", "tryAgain": "重试",
"couldNotOpenApp": "\u65e0\u6cd5\u6253\u5f00 Focus Seed\u3002", "couldNotOpenApp": "无法打开 Focus Seed。",
"openFailed": "\u672c\u5730\u6570\u636e\u6253\u5f00\u5931\u8d25\u3002", "loadingLocalData": "正在读取本地数据",
"saveFailed": "\u4fdd\u5b58\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002", "openFailed": "本地数据打开失败。",
"goodMorning": "\u65e9\u4e0a\u597d", "saveFailed": "保存失败,请重试。",
"goodDay": "\u4eca\u5929\u597d", "goodMorning": "早上好",
"goodEvening": "\u665a\u4e0a\u597d", "goodDay": "今天好",
"goodNight": "\u591c\u6df1\u4e86", "goodEvening": "晚上好",
"goodNight": "夜深了",
"readableDate": "{date} - {weekday}", "readableDate": "{date} - {weekday}",
"@readableDate": { "@readableDate": {
"placeholders": { "placeholders": {
@@ -26,20 +27,32 @@
} }
} }
}, },
"todayIntentionLabel": "\u4eca\u65e5\u91cd\u70b9", "todayIntentionLabel": "今日重点",
"chooseOneOutcome": "\u5199\u4e0b\u4eca\u5929\u7684\u91cd\u70b9", "chooseOneOutcome": "写下今天的重点",
"focusLabel": "\u4e13\u6ce8", "focusLabel": "专注",
"timerReady": "\u51c6\u5907\u597d\u4e86\u5c31\u5f00\u59cb", "timerReady": "准备好了就开始",
"timerRunning": "\u4e13\u6ce8\u4e2d", "timerRunning": "专注中",
"timerPaused": "\u5df2\u6682\u505c", "timerPaused": "已暂停",
"timerCompleted": "\u5df2\u5b8c\u6210", "timerCompleted": "已完成",
"reflectOneMinute": "\u590d\u76d8 1 \u5206\u949f", "focusCurrentIntention": "今日重点:{intention}",
"startAnotherSession": "\u518d\u6765\u4e00\u8f6e", "@focusCurrentIntention": {
"setTodaysIntention": "\u8bbe\u7f6e\u4eca\u65e5\u91cd\u70b9", "placeholders": {
"pause": "\u6682\u505c", "intention": {
"end": "\u7ed3\u675f", "type": "String"
"resume": "\u7ee7\u7eed", }
"startMinutes": "\u5f00\u59cb {minutes} \u5206\u949f", }
},
"focusNoIntentionTitle": "先开始这一段",
"reflectOneMinute": "写一句进展",
"startFocus": "开始专注",
"startAnotherSession": "再来一轮",
"setTodaysIntention": "设置今日重点",
"pause": "暂停",
"end": "结束",
"completeSession": "完成本轮",
"continueSession": "继续本轮",
"resume": "继续",
"startMinutes": "开始 {minutes} 分钟",
"@startMinutes": { "@startMinutes": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
@@ -47,9 +60,9 @@
} }
} }
}, },
"resetTimer": "\u91cd\u7f6e\u8ba1\u65f6", "resetTimer": "重置计时",
"todaysHabits": "\u4eca\u65e5\u4e60\u60ef", "todaysHabits": "今日习惯",
"completedCount": "{completed}/{total} \u5b8c\u6210", "completedCount": "{completed}/{total} 完成",
"@completedCount": { "@completedCount": {
"placeholders": { "placeholders": {
"completed": { "completed": {
@@ -60,11 +73,11 @@
} }
} }
}, },
"manageHabits": "\u7ba1\u7406\u4e60\u60ef", "manageHabits": "管理习惯",
"addHabit": "\u6dfb\u52a0\u4e60\u60ef", "addHabit": "添加习惯",
"add": "\u6dfb\u52a0", "add": "添加",
"emptyHabitPrompt": "\u6dfb\u52a0\u4e00\u4e2a\u53ef\u91cd\u590d\u7684\u5c0f\u4e60\u60ef\u3002", "emptyHabitPrompt": "保持可选,也保持很小。",
"viewAllHabits": "\u67e5\u770b\u5168\u90e8 {count} \u4e2a", "viewAllHabits": "查看全部 {count} 个",
"@viewAllHabits": { "@viewAllHabits": {
"placeholders": { "placeholders": {
"count": { "count": {
@@ -72,17 +85,17 @@
} }
} }
}, },
"reflectionSaved": "\u590d\u76d8\u5df2\u4fdd\u5b58", "reflectionSaved": "复盘已保存",
"reflectionPromptTitle": "\u590d\u76d8\u4e00\u4e0b", "reflectionPromptTitle": "复盘一下",
"reflectionPromptBody": "\u5199\u4e00\u53e5\u6709\u7528\u7684\u603b\u7ed3\u3002", "reflectionPromptBody": "写一句有用的总结。",
"progressIntention": "\u76ee\u6807", "progressIntention": "目标",
"progressFocus": "\u4e13\u6ce8", "progressFocus": "专注",
"progressHabits": "\u4e60\u60ef", "progressHabits": "习惯",
"progressReflect": "\u590d\u76d8", "progressReflect": "复盘",
"done": "\u5b8c\u6210", "done": "完成",
"next": "\u4e0b\u4e00\u6b65", "next": "下一步",
"later": "\u7a0d\u540e", "later": "稍后",
"minutesShort": "{minutes} \u5206\u949f", "minutesShort": "{minutes} 分钟",
"@minutesShort": { "@minutesShort": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
@@ -90,7 +103,7 @@
} }
} }
}, },
"dailyStepsComplete": "\u4eca\u65e5\u5b8c\u6210 {completed}/4", "dailyStepsComplete": "今日完成 {completed}/3",
"@dailyStepsComplete": { "@dailyStepsComplete": {
"placeholders": { "placeholders": {
"completed": { "completed": {
@@ -98,94 +111,100 @@
} }
} }
}, },
"habitOptions": "\u4e60\u60ef\u9009\u9879", "habitOptions": "习惯选项",
"rename": "\u91cd\u547d\u540d", "rename": "重命名",
"delete": "\u5220\u9664", "delete": "删除",
"habitsTitle": "\u4e60\u60ef", "habitsTitle": "辅助习惯",
"habitsSubtitle": "\u5c11\u91cf\u3001\u53ef\u91cd\u590d\u3002", "habitsSubtitle": "可选的重复动作,今日重点仍然放在前面。",
"emptyHabitsTitle": "\u6dfb\u52a0\u5c0f\u4e60\u60ef", "emptyHabitsTitle": "添加一个辅助习惯",
"addHabitTitle": "\u6dfb\u52a0\u4e60\u60ef", "addHabitTitle": "添加习惯",
"renameHabitTitle": "\u91cd\u547d\u540d\u4e60\u60ef", "renameHabitTitle": "重命名习惯",
"habitHint": "\u4f8b\u5982\uff1a\u8bfb 10 \u9875\u4e66", "habitHint": "例如:读 10 页书",
"cancel": "\u53d6\u6d88", "cancel": "取消",
"save": "\u4fdd\u5b58", "save": "保存",
"setIntentionTitle": "\u8bbe\u7f6e\u76ee\u6807", "textEntryRequired": "先写一个短句。",
"editIntentionTitle": "\u7f16\u8f91\u76ee\u6807", "setIntentionTitle": "设置今日重点",
"intentionHint": "\u4eca\u5929\u6700\u91cd\u8981\u7684\u662f\u4ec0\u4e48\uff1f", "editIntentionTitle": "编辑今日重点",
"deleteHabitTitle": "\u5220\u9664\u4e60\u60ef\uff1f", "intentionHint": "今天最重要的是什么?",
"deleteHabitBody": "\u8fd9\u4e2a\u4e60\u60ef\u7684\u5386\u53f2\u5b8c\u6210\u8bb0\u5f55\u4e5f\u4f1a\u5220\u9664\u3002", "deleteHabitTitle": "删除习惯?",
"close": "\u5173\u95ed", "deleteHabitBody": "这个习惯的历史完成记录也会删除。",
"copy": "\u590d\u5236", "close": "关闭",
"clearTodayDataTitle": "\u6e05\u7406\u4eca\u65e5\u6570\u636e\uff1f", "copy": "复制",
"clearTodayDataBody": "\u53ea\u91cd\u7f6e\u4eca\u5929\u7684\u91cd\u70b9\u3001\u6253\u5361\u3001\u4e13\u6ce8\u8bb0\u5f55\u548c\u590d\u76d8\uff0c\u8fc7\u53bb\u65e5\u671f\u7684\u6570\u636e\u4f1a\u4fdd\u7559\u3002", "clearTodayDataTitle": "清理今日数据?",
"clearAllDataTitle": "\u5220\u9664\u5168\u90e8\u672c\u5730\u6570\u636e\uff1f", "clearTodayDataBody": "只重置今天的重点、打卡、专注记录和复盘,过去日期的数据会保留。",
"clearAllDataBody": "\u4f1a\u5220\u9664\u672c\u673a\u7684\u76ee\u6807\u3001\u4e60\u60ef\u3001\u4e13\u6ce8\u8bb0\u5f55\u548c\u590d\u76d8\uff0c\u65e0\u6cd5\u64a4\u9500\u3002", "clearAllDataTitle": "删除全部本地数据?",
"clear": "\u6e05\u9664", "clearAllDataBody": "会删除本机的目标、习惯、专注记录和复盘,无法撤销。",
"deleteAll": "\u5168\u90e8\u5220\u9664", "clear": "清除",
"privacyTitle": "\u9690\u79c1\u4e0e\u6570\u636e", "deleteAll": "全部删除",
"privacyBodyOne": "Focus Seed \u53ea\u5728\u672c\u673a\u4fdd\u5b58\u76ee\u6807\u3001\u4e60\u60ef\u3001\u4e13\u6ce8\u8bb0\u5f55\u3001\u590d\u76d8\u548c\u8bbe\u7f6e\u3002\u5f53\u524d\u7248\u672c\u6ca1\u6709\u767b\u5f55\u3001\u4e91\u540c\u6b65\u3001\u5e7f\u544a\u3001\u5206\u6790 SDK \u6216\u4ed8\u8d39 SDK\u3002", "privacyTitle": "隐私与数据",
"privacyBodyTwo": "\u4f60\u53ef\u4ee5\u968f\u65f6\u6e05\u7406\u4eca\u65e5\u8fdb\u5ea6\uff0c\u6216\u5220\u9664\u672c\u673a\u7684\u5168\u90e8\u672c\u5730\u6570\u636e\u3002", "privacyBodyOne": "Focus Seed 只在本机保存目标、习惯、专注记录、复盘和设置。当前版本没有登录、云同步、广告、分析 SDK 或付费 SDK。",
"reflectionEmptyError": "\u5199\u4e00\u53e5\u771f\u5b9e\u7684\u603b\u7ed3\u3002", "privacyBodyTwo": "你可以随时在设置中清理今日进度,或删除本机的全部本地数据。",
"reflectionSaveError": "\u4fdd\u5b58\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002", "reflectionEmptyError": "写一句真实的总结。",
"reflectionSheetTitle": "\u590d\u76d8\u4e00\u4e0b", "reflectionSaveError": "保存失败,请重试。",
"reflectionSheetSubtitle": "\u4fdd\u7559\u6709\u6548\u7684\uff0c\u8c03\u6574\u4e00\u4e2a\u70b9\u3002", "reflectionSheetTitle": "留下一句进展",
"reflectionHint": "\u4e00\u53e5\u6709\u7528\u7684\u8bdd\u5c31\u591f\u3002", "reflectionSheetSubtitle": "记下刚推进了什么,下次就知道从哪里继续。",
"savingLabel": "\u4fdd\u5b58\u4e2d", "reflectionHint": "刚才推进了什么?",
"saveReflection": "\u4fdd\u5b58\u590d\u76d8", "reflectionMoodLabel": "状态可以稍后补",
"moodCalm": "\u5e73\u9759", "savingLabel": "保存中",
"moodClear": "\u6e05\u9192", "saveReflection": "保存记录",
"moodProud": "\u6ee1\u610f", "moodCalm": "平静",
"moodTired": "\u75b2\u60eb", "moodClear": "清醒",
"meTitle": "\u6211\u7684\u82b1\u56ed", "moodProud": "满意",
"meSubtitle": "\u504f\u597d\u548c\u8bb0\u5f55\u90fd\u53ea\u4fdd\u5b58\u5728\u672c\u673a\u3002", "moodTired": "疲惫",
"appearance": "\u4e3b\u9898", "meTitle": "我的",
"system": "\u7cfb\u7edf", "meSubtitle": "管理你的节奏、外观和记录。",
"light": "\u6d45\u8272", "appSettingsTitle": "应用设置",
"dark": "\u6df1\u8272", "appearance": "主题",
"language": "\u8bed\u8a00", "system": "系统",
"developmentOnly": "\u4ec5\u5f00\u53d1\u73af\u5883", "light": "浅色",
"languageDefault": "\u9ed8\u8ba4", "dark": "深色",
"language": "语言",
"developmentOnly": "仅开发环境",
"languageDefault": "默认",
"languageEnglish": "English", "languageEnglish": "English",
"languageChinese": "\u4e2d\u6587", "languageChinese": "中文",
"preferencesTitle": "\u504f\u597d", "preferencesTitle": "偏好",
"preferencesDetail": "\u8ba9\u754c\u9762\u66f4\u987a\u624b", "preferencesDetail": "主题、语言和默认专注",
"localData": "\u672c\u5730\u8bb0\u5f55", "defaultFocusDuration": "默认专注",
"localDataDetail": "\u53ea\u5728\u672c\u673a", "defaultFocusDurationDetail": "下一轮",
"personalGardenTitle": "\u4f60\u7684\u4e13\u6ce8\u82b1\u56ed", "localData": "本地记录",
"personalGardenSubtitle": "\u6bcf\u4e00\u6b21\u5c0f\u4e13\u6ce8\uff0c\u90fd\u4f1a\u7559\u4e0b\u53ef\u56de\u770b\u7684\u75d5\u8ff9\u3002", "localDataDetail": "只在本机",
"focusDataLabel": "\u4e13\u6ce8", "personalGardenTitle": "我的累计",
"habitsDataLabel": "\u4e60\u60ef", "personalGardenSubtitle": "你的本地行动记录、进展和应用控制都保留在这台设备上。",
"notesDataLabel": "\u8bb0\u5f55", "focusDataLabel": "专注",
"clearToday": "\u6e05\u7406\u4eca\u5929", "habitsDataLabel": "习惯",
"clearTodaySubtitle": "\u53ea\u91cd\u7f6e\u4eca\u5929\u7684\u95ed\u73af\u3002", "notesDataLabel": "进展",
"privacySubtitle": "\u65e0\u8d26\u53f7\uff0c\u65e0\u4e91\u540c\u6b65\u3002", "totalActionTimeLabel": "行动时间",
"deleteAllLocalData": "\u5220\u9664\u5168\u90e8\u6570\u636e", "recordsCountLabel": "记录",
"deleteAllLocalDataSubtitle": "\u79fb\u9664\u8fd9\u53f0\u8bbe\u5907\u4e0a\u7684 Focus Seed \u8bb0\u5f55\u3002", "progressNotesCountLabel": "进展",
"todayDataCleared": "\u4eca\u65e5\u6570\u636e\u5df2\u6e05\u7406", "clearToday": "清理今天",
"localDataCleared": "\u672c\u5730\u6570\u636e\u5df2\u5220\u9664", "clearTodaySubtitle": "只重置今天的闭环。",
"journeyTitle": "\u8fdb\u5c55", "privacySubtitle": "无账号,无云同步。",
"journeyOverviewLabel": "\u4eca\u65e5\u95ed\u73af", "deleteAllLocalData": "删除全部数据",
"journeyOverviewStartTitle": "\u5148\u5b9a\u4e0b\u4e00\u4e2a\u91cd\u70b9", "deleteAllLocalDataSubtitle": "移除这台设备上的 Focus Seed 记录。",
"journeyOverviewStartBody": "\u5199\u4e0b\u4eca\u5929\u6700\u503c\u5f97\u5b8c\u6210\u7684\u90a3\u4ef6\u4e8b\u3002", "todayDataCleared": "今日数据已清理",
"journeyOverviewActiveTitle": "\u4eca\u5929\u6b63\u5728\u6210\u5f62", "localDataCleared": "本地数据已删除",
"journeyOverviewActiveBody": "\u7ee7\u7eed\u63a8\u8fdb\uff1a\u4e13\u6ce8\u3001\u6253\u5361\uff0c\u7136\u540e\u7528\u4e00\u53e5\u8bdd\u590d\u76d8\u3002", "todayLoopLabel": "今日闭环",
"journeyOverviewDoneTitle": "\u4eca\u65e5\u95ed\u73af\u5b8c\u6210", "todayLoopStartTitle": "先定下一个重点",
"journeyOverviewDoneBody": "\u4f60\u5df2\u7ecf\u7ed9\u672a\u6765\u7684\u81ea\u5df1\u7559\u4e0b\u4e86\u4e00\u6761\u6e05\u6670\u8bb0\u5f55\u3002", "todayLoopStartBody": "先写下今天最重要的一件事。",
"journeyOverviewProgress": "\u5df2\u5b8c\u6210 {completed}/4", "todayLoopActiveTitle": "今天正在成形",
"@journeyOverviewProgress": { "todayLoopActiveBody": "先完成一轮专注,再留一句有用的记录。",
"todayLoopDoneTitle": "进展已保存",
"todayLoopDoneBody": "今天已经推进了一点,下次也知道从哪里继续。",
"todayLoopProgress": "已完成 {completed}/3",
"@todayLoopProgress": {
"placeholders": { "placeholders": {
"completed": { "completed": {
"type": "int" "type": "int"
} }
} }
}, },
"reviewNow": "\u73b0\u5728\u590d\u76d8", "reviewNow": "写一句记录",
"journeyStatsTitle": "7 \u65e5\u8282\u594f", "weekFocusLabel": "专注",
"weekFocusLabel": "\u4e13\u6ce8", "streakLabel": "连续",
"streakLabel": "\u8fde\u7eed", "activeDaysLabel": "活跃",
"activeDaysLabel": "\u6d3b\u8dc3", "habitRateLabel": "习惯",
"habitRateLabel": "\u4e60\u60ef", "daysValue": "{count}天",
"daysValue": "{count}\u5929",
"@daysValue": { "@daysValue": {
"placeholders": { "placeholders": {
"count": { "count": {
@@ -201,17 +220,36 @@
} }
} }
}, },
"viewHistory": "\u8bb0\u5f55\u5e93", "viewHistory": "记录库",
"viewHistorySubtitle": "\u67e5\u770b\u4e13\u6ce8\u8bb0\u5f55\u548c\u590d\u76d8\u3002", "viewHistorySubtitle": "查看专注记录和复盘。",
"historyTitle": "\u5386\u53f2", "historyTitle": "历史",
"focusHistory": "\u4e13\u6ce8", "focusHistory": "专注",
"notesHistory": "\u590d\u76d8", "notesHistory": "复盘",
"emptyFocusHistoryTitle": "\u8fd8\u6ca1\u6709\u4e13\u6ce8\u8bb0\u5f55", "emptyFocusHistoryTitle": "还没有专注记录",
"emptyFocusHistoryBody": "\u4ece\u4eca\u65e5\u9875\u5f00\u59cb\u4e00\u8f6e\u4e13\u6ce8\uff0c\u5b8c\u6210\u540e\u4f1a\u51fa\u73b0\u5728\u8fd9\u91cc\u3002", "emptyFocusHistoryBody": "完成一轮专注后,记录会出现在这里。",
"emptyNotesHistoryTitle": "\u8fd8\u6ca1\u6709\u590d\u76d8", "emptyNotesHistoryTitle": "还没有复盘",
"emptyNotesHistoryBody": "\u4e13\u6ce8\u540e\u4fdd\u5b58\u4e00\u53e5\u590d\u76d8\uff0c\u8ba9\u8fdb\u5c55\u66f4\u6e05\u6670\u3002", "emptyNotesHistoryBody": "专注后留一句复盘,这里会保留它。",
"recentFocus": "\u6700\u8fd1\u4e13\u6ce8", "recordsEmptyTitle": "还没有进展记录",
"sessionsTotal": "\u5171 {count} \u6b21", "recordsEmptyBody": "完成一次专注,再留一句记录,这里就会保留今天的进展。",
"recordsCalendarTitle": "活动日期",
"recordsDateLedgerTitle": "当日账本",
"backToToday": "回到今日",
"recordsNoIntention": "未命名今日重点",
"recordsNoReflection": "还没有进展记录",
"recordsMissingProgressHelper": "补一句,之后更容易接上。",
"addProgressNote": "补一句进展",
"continueThisAction": "继续这件事",
"replaceTodayActionTitle": "替换今天的行动?",
"replaceTodayActionBody": "会把今天的行动改为“{actionTitle}”,已有记录不会删除。",
"@replaceTodayActionBody": {
"placeholders": {
"actionTitle": {
"type": "String"
}
}
},
"recentFocus": "最近专注",
"sessionsTotal": "共 {count} 次",
"@sessionsTotal": { "@sessionsTotal": {
"placeholders": { "placeholders": {
"count": { "count": {
@@ -219,7 +257,7 @@
} }
} }
}, },
"focusMinutes": "\u4e13\u6ce8 {minutes} \u5206\u949f", "focusMinutes": "专注 {minutes} 分钟",
"@focusMinutes": { "@focusMinutes": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
@@ -227,9 +265,9 @@
} }
} }
}, },
"recentReflections": "\u6700\u8fd1\u590d\u76d8", "recentReflections": "最近复盘",
"recentReflectionsDetail": "\u503c\u5f97\u4fdd\u7559\u7684\u8bb0\u5f55", "recentReflectionsDetail": "值得保留的记录",
"focusedMinutes": "\u5df2\u4e13\u6ce8 {minutes} \u5206\u949f", "focusedMinutes": "已专注 {minutes} 分钟",
"@focusedMinutes": { "@focusedMinutes": {
"placeholders": { "placeholders": {
"minutes": { "minutes": {
@@ -237,8 +275,8 @@
} }
} }
}, },
"noFocusSessionYet": "\u8fd8\u6ca1\u6709\u5b8c\u6210\u4e13\u6ce8", "noFocusSessionYet": "还没有完成专注",
"habitsCompleted": "{completed}/{total} \u5b8c\u6210", "habitsCompleted": "{completed}/{total} 完成",
"@habitsCompleted": { "@habitsCompleted": {
"placeholders": { "placeholders": {
"completed": { "completed": {
@@ -249,33 +287,63 @@
} }
} }
}, },
"reflectionCaptured": "\u4eca\u65e5\u590d\u76d8\u5df2\u8bb0\u5f55", "reflectionCaptured": "今日复盘已记录",
"reflectionAction": "\u7528\u4e00\u53e5\u8bdd\u6536\u5c3e", "reflectionAction": "用一句话给今天收尾",
"growthTitle": "\u6210\u957f", "nextActionTitle": "下一步",
"growthFlowerOpen": "\u4eca\u65e5\u82b1\u6735\u5df2\u5f00\u653e", "nextActionSetFocusTitle": "写下今天的重点",
"growthLocked": "\u590d\u76d8\u540e\u89e3\u9501", "nextActionSetFocusBody": "一个短句就够了,之后还可以调整。",
"sevenDayGrowth": "7 \u65e5\u6210\u957f", "nextActionStartFocusTitle": "开始 10 分钟",
"sevenDayGrowthSubtitle": "\u8fde\u7eed\u884c\u52a8\u4f1a\u88ab\u770b\u89c1\u3002", "nextActionStartFocusBody": "写不写都可以,先开始。",
"growthSeed": "\u79cd\u5b50", "nextActionStartActionBody": "先推进一小段,完成后留一句进展。",
"growthSprout": "\u53d1\u82bd", "nextActionHabitsTitle": "完成一个小习惯",
"growthLeaves": "\u751f\u957f", "nextActionHabitsBody": "趁着还顺手,先做掉一个可重复的小动作。",
"growthFlower": "\u5f00\u82b1", "nextActionReflectTitle": "写一句记录",
"growthDaySemantic": "{date}\uff0c{stage}", "nextActionReflectBody": "记下刚才推进了什么,下次更容易继续。",
"@growthDaySemantic": { "nextActionCompleteTitle": "今天已经推进了一点",
"nextActionCompleteBody": "进展已经留下,想继续时再来一段。",
"nextActionContinueFocus": "再专注 {minutes} 分钟",
"@nextActionContinueFocus": {
"placeholders": { "placeholders": {
"date": { "minutes": {
"type": "Object" "type": "int"
},
"stage": {
"type": "Object"
} }
} }
}, },
"growthSeedSemantic": "\u7b49\u5f85\u4eca\u65e5\u76ee\u6807\u7684\u79cd\u5b50", "habitTemplatesTitle": "可以直接开始",
"growthSproutSemantic": "\u76ee\u6807\u5df2\u79cd\u4e0b\uff0c\u6b63\u5728\u53d1\u82bd", "habitTemplatesBody": "添加一个很小但能重复的动作。",
"growthLeavesSemantic": "\u4eca\u65e5\u8fdb\u5ea6\u8ba9\u690d\u7269\u751f\u957f", "habitTemplateStretch": "2 分钟拉伸",
"growthFlowerSemantic": "\u4eca\u65e5\u95ed\u73af\u5b8c\u6210\uff0c\u82b1\u6735\u5f00\u653e", "habitTemplateRead": "读 10 页书",
"defaultHabitPlanTask": "\u8ba1\u5212\u4e00\u4e2a\u660e\u786e\u4efb\u52a1", "habitTemplateWalk": "出门走走",
"defaultHabitFocus": "\u4e13\u6ce8 25 \u5206\u949f", "habitTemplateNoScroll": "午餐不刷手机",
"defaultHabitReflect": "\u5199\u4e00\u53e5\u590d\u76d8" "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": "写一句复盘"
} }
+445 -103
View File
@@ -125,11 +125,11 @@ abstract class AppLocalizations {
/// **'Today'** /// **'Today'**
String get navToday; String get navToday;
/// No description provided for @navJourney. /// No description provided for @navRecords.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Journey'** /// **'Records'**
String get navJourney; String get navRecords;
/// No description provided for @navMe. /// No description provided for @navMe.
/// ///
@@ -149,10 +149,16 @@ abstract class AppLocalizations {
/// **'Could not open Focus Seed.'** /// **'Could not open Focus Seed.'**
String get couldNotOpenApp; String get couldNotOpenApp;
/// No description provided for @loadingLocalData.
///
/// In en, this message translates to:
/// **'Loading local data'**
String get loadingLocalData;
/// No description provided for @openFailed. /// No description provided for @openFailed.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Your local garden could not be opened.'** /// **'Your local data could not be opened.'**
String get openFailed; String get openFailed;
/// No description provided for @saveFailed. /// No description provided for @saveFailed.
@@ -233,12 +239,30 @@ abstract class AppLocalizations {
/// **'Session complete'** /// **'Session complete'**
String get timerCompleted; String get timerCompleted;
/// No description provided for @focusCurrentIntention.
///
/// In en, this message translates to:
/// **'Today\'s focus: {intention}'**
String focusCurrentIntention(String intention);
/// No description provided for @focusNoIntentionTitle.
///
/// In en, this message translates to:
/// **'Start this session first'**
String get focusNoIntentionTitle;
/// No description provided for @reflectOneMinute. /// No description provided for @reflectOneMinute.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Reflect for 1 min'** /// **'Write progress note'**
String get reflectOneMinute; String get reflectOneMinute;
/// No description provided for @startFocus.
///
/// In en, this message translates to:
/// **'Start focus'**
String get startFocus;
/// No description provided for @startAnotherSession. /// No description provided for @startAnotherSession.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -263,6 +287,18 @@ abstract class AppLocalizations {
/// **'End'** /// **'End'**
String get end; String get end;
/// No description provided for @completeSession.
///
/// In en, this message translates to:
/// **'Complete session'**
String get completeSession;
/// No description provided for @continueSession.
///
/// In en, this message translates to:
/// **'Continue session'**
String get continueSession;
/// No description provided for @resume. /// No description provided for @resume.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -314,7 +350,7 @@ abstract class AppLocalizations {
/// No description provided for @emptyHabitPrompt. /// No description provided for @emptyHabitPrompt.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add one repeatable habit.'** /// **'Keep this optional and small.'**
String get emptyHabitPrompt; String get emptyHabitPrompt;
/// No description provided for @viewAllHabits. /// No description provided for @viewAllHabits.
@@ -392,7 +428,7 @@ abstract class AppLocalizations {
/// No description provided for @dailyStepsComplete. /// No description provided for @dailyStepsComplete.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'{completed}/4 daily steps complete'** /// **'{completed}/3 daily steps complete'**
String dailyStepsComplete(int completed); String dailyStepsComplete(int completed);
/// No description provided for @habitOptions. /// No description provided for @habitOptions.
@@ -416,19 +452,19 @@ abstract class AppLocalizations {
/// No description provided for @habitsTitle. /// No description provided for @habitsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Habits'** /// **'Supporting habits'**
String get habitsTitle; String get habitsTitle;
/// No description provided for @habitsSubtitle. /// No description provided for @habitsSubtitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Keep it small and repeatable.'** /// **'Optional repeatable steps. Today\'s focus still comes first.'**
String get habitsSubtitle; String get habitsSubtitle;
/// No description provided for @emptyHabitsTitle. /// No description provided for @emptyHabitsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add a small habit'** /// **'Add a supporting habit'**
String get emptyHabitsTitle; String get emptyHabitsTitle;
/// No description provided for @addHabitTitle. /// No description provided for @addHabitTitle.
@@ -461,16 +497,22 @@ abstract class AppLocalizations {
/// **'Save'** /// **'Save'**
String get save; String get save;
/// No description provided for @textEntryRequired.
///
/// In en, this message translates to:
/// **'Write a short line first.'**
String get textEntryRequired;
/// No description provided for @setIntentionTitle. /// No description provided for @setIntentionTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Set intention'** /// **'Set today\'s focus'**
String get setIntentionTitle; String get setIntentionTitle;
/// No description provided for @editIntentionTitle. /// No description provided for @editIntentionTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Edit intention'** /// **'Edit today\'s focus'**
String get editIntentionTitle; String get editIntentionTitle;
/// No description provided for @intentionHint. /// No description provided for @intentionHint.
@@ -554,7 +596,7 @@ abstract class AppLocalizations {
/// No description provided for @privacyBodyTwo. /// No description provided for @privacyBodyTwo.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'You can clear today\'s progress or delete all local data from Me at any time.'** /// **'You can clear today\'s progress or delete all local data from settings at any time.'**
String get privacyBodyTwo; String get privacyBodyTwo;
/// No description provided for @reflectionEmptyError. /// No description provided for @reflectionEmptyError.
@@ -572,21 +614,27 @@ abstract class AppLocalizations {
/// No description provided for @reflectionSheetTitle. /// No description provided for @reflectionSheetTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'How did it go?'** /// **'Leave one progress note'**
String get reflectionSheetTitle; String get reflectionSheetTitle;
/// No description provided for @reflectionSheetSubtitle. /// No description provided for @reflectionSheetSubtitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Keep what worked. Adjust one thing.'** /// **'Save what moved forward so next time has a clear start.'**
String get reflectionSheetSubtitle; String get reflectionSheetSubtitle;
/// No description provided for @reflectionHint. /// No description provided for @reflectionHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'One useful sentence is enough.'** /// **'What moved forward?'**
String get reflectionHint; String get reflectionHint;
/// No description provided for @reflectionMoodLabel.
///
/// In en, this message translates to:
/// **'Mood is optional'**
String get reflectionMoodLabel;
/// No description provided for @savingLabel. /// No description provided for @savingLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -596,7 +644,7 @@ abstract class AppLocalizations {
/// No description provided for @saveReflection. /// No description provided for @saveReflection.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Save reflection'** /// **'Save note'**
String get saveReflection; String get saveReflection;
/// No description provided for @moodCalm. /// No description provided for @moodCalm.
@@ -626,15 +674,21 @@ abstract class AppLocalizations {
/// No description provided for @meTitle. /// No description provided for @meTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Your garden'** /// **'Me'**
String get meTitle; String get meTitle;
/// No description provided for @meSubtitle. /// No description provided for @meSubtitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Preferences and records stay on this device.'** /// **'Manage your rhythm, appearance, and records.'**
String get meSubtitle; String get meSubtitle;
/// No description provided for @appSettingsTitle.
///
/// In en, this message translates to:
/// **'App settings'**
String get appSettingsTitle;
/// No description provided for @appearance. /// No description provided for @appearance.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -698,9 +752,21 @@ abstract class AppLocalizations {
/// No description provided for @preferencesDetail. /// No description provided for @preferencesDetail.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Make it feel right'** /// **'Theme, language, and default focus'**
String get preferencesDetail; String get preferencesDetail;
/// No description provided for @defaultFocusDuration.
///
/// In en, this message translates to:
/// **'Default focus'**
String get defaultFocusDuration;
/// No description provided for @defaultFocusDurationDetail.
///
/// In en, this message translates to:
/// **'Next session'**
String get defaultFocusDurationDetail;
/// No description provided for @localData. /// No description provided for @localData.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -716,13 +782,13 @@ abstract class AppLocalizations {
/// No description provided for @personalGardenTitle. /// No description provided for @personalGardenTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Your focus garden'** /// **'My accumulation'**
String get personalGardenTitle; String get personalGardenTitle;
/// No description provided for @personalGardenSubtitle. /// No description provided for @personalGardenSubtitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Every small session leaves a trace you can return to.'** /// **'Your local action records, progress notes, and app controls stay on this device.'**
String get personalGardenSubtitle; String get personalGardenSubtitle;
/// No description provided for @focusDataLabel. /// No description provided for @focusDataLabel.
@@ -740,9 +806,27 @@ abstract class AppLocalizations {
/// No description provided for @notesDataLabel. /// No description provided for @notesDataLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Notes'** /// **'Progress'**
String get notesDataLabel; String get notesDataLabel;
/// No description provided for @totalActionTimeLabel.
///
/// In en, this message translates to:
/// **'Action time'**
String get totalActionTimeLabel;
/// No description provided for @recordsCountLabel.
///
/// In en, this message translates to:
/// **'Records'**
String get recordsCountLabel;
/// No description provided for @progressNotesCountLabel.
///
/// In en, this message translates to:
/// **'Progress notes'**
String get progressNotesCountLabel;
/// No description provided for @clearToday. /// No description provided for @clearToday.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -785,72 +869,60 @@ abstract class AppLocalizations {
/// **'Local data deleted'** /// **'Local data deleted'**
String get localDataCleared; String get localDataCleared;
/// No description provided for @journeyTitle. /// No description provided for @todayLoopLabel.
///
/// In en, this message translates to:
/// **'Progress'**
String get journeyTitle;
/// No description provided for @journeyOverviewLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'TODAY\'S LOOP'** /// **'TODAY\'S LOOP'**
String get journeyOverviewLabel; String get todayLoopLabel;
/// No description provided for @journeyOverviewStartTitle. /// No description provided for @todayLoopStartTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Start with one clear focus'** /// **'Start with one clear focus'**
String get journeyOverviewStartTitle; String get todayLoopStartTitle;
/// No description provided for @journeyOverviewStartBody. /// No description provided for @todayLoopStartBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Pick the thing that would make today feel finished.'** /// **'Pick the one result that matters today.'**
String get journeyOverviewStartBody; String get todayLoopStartBody;
/// No description provided for @journeyOverviewActiveTitle. /// No description provided for @todayLoopActiveTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Your day is taking shape'** /// **'Your day is taking shape'**
String get journeyOverviewActiveTitle; String get todayLoopActiveTitle;
/// No description provided for @journeyOverviewActiveBody. /// No description provided for @todayLoopActiveBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Keep the loop moving: focus, habits, then a short reflection.'** /// **'Focus once, then leave one useful note.'**
String get journeyOverviewActiveBody; String get todayLoopActiveBody;
/// No description provided for @journeyOverviewDoneTitle. /// No description provided for @todayLoopDoneTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Loop complete'** /// **'Progress saved'**
String get journeyOverviewDoneTitle; String get todayLoopDoneTitle;
/// No description provided for @journeyOverviewDoneBody. /// No description provided for @todayLoopDoneBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'You left a clean record for future you.'** /// **'You moved today forward and left a clear place to continue.'**
String get journeyOverviewDoneBody; String get todayLoopDoneBody;
/// No description provided for @journeyOverviewProgress. /// No description provided for @todayLoopProgress.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'{completed}/4 done'** /// **'{completed}/3 done'**
String journeyOverviewProgress(int completed); String todayLoopProgress(int completed);
/// No description provided for @reviewNow. /// No description provided for @reviewNow.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Reflect now'** /// **'Write a note'**
String get reviewNow; String get reviewNow;
/// No description provided for @journeyStatsTitle.
///
/// In en, this message translates to:
/// **'7-day rhythm'**
String get journeyStatsTitle;
/// No description provided for @weekFocusLabel. /// No description provided for @weekFocusLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -926,7 +998,7 @@ abstract class AppLocalizations {
/// No description provided for @emptyFocusHistoryBody. /// No description provided for @emptyFocusHistoryBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Start one session from Today and it will appear here.'** /// **'Finish one focus session and it will appear here.'**
String get emptyFocusHistoryBody; String get emptyFocusHistoryBody;
/// No description provided for @emptyNotesHistoryTitle. /// No description provided for @emptyNotesHistoryTitle.
@@ -938,9 +1010,81 @@ abstract class AppLocalizations {
/// No description provided for @emptyNotesHistoryBody. /// No description provided for @emptyNotesHistoryBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Save a short reflection after focus to make your progress visible.'** /// **'Save one reflection after a session and it will appear here.'**
String get emptyNotesHistoryBody; String get emptyNotesHistoryBody;
/// No description provided for @recordsEmptyTitle.
///
/// In en, this message translates to:
/// **'No progress records yet'**
String get recordsEmptyTitle;
/// No description provided for @recordsEmptyBody.
///
/// In en, this message translates to:
/// **'Finish one focus session and leave a note to keep today\'s progress here.'**
String get recordsEmptyBody;
/// No description provided for @recordsCalendarTitle.
///
/// In en, this message translates to:
/// **'Activity dates'**
String get recordsCalendarTitle;
/// No description provided for @recordsDateLedgerTitle.
///
/// In en, this message translates to:
/// **'Date ledger'**
String get recordsDateLedgerTitle;
/// No description provided for @backToToday.
///
/// In en, this message translates to:
/// **'Back to Today'**
String get backToToday;
/// No description provided for @recordsNoIntention.
///
/// In en, this message translates to:
/// **'Unnamed daily focus'**
String get recordsNoIntention;
/// No description provided for @recordsNoReflection.
///
/// In en, this message translates to:
/// **'No progress note yet'**
String get recordsNoReflection;
/// No description provided for @recordsMissingProgressHelper.
///
/// In en, this message translates to:
/// **'Add one line so this is easier to pick up later.'**
String get recordsMissingProgressHelper;
/// No description provided for @addProgressNote.
///
/// In en, this message translates to:
/// **'Add progress note'**
String get addProgressNote;
/// No description provided for @continueThisAction.
///
/// In en, this message translates to:
/// **'Continue this'**
String get continueThisAction;
/// No description provided for @replaceTodayActionTitle.
///
/// In en, this message translates to:
/// **'Replace today\'s action?'**
String get replaceTodayActionTitle;
/// No description provided for @replaceTodayActionBody.
///
/// In en, this message translates to:
/// **'This will change today\'s action to \"{actionTitle}\". Existing records will stay.'**
String replaceTodayActionBody(String actionTitle);
/// No description provided for @recentFocus. /// No description provided for @recentFocus.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -998,92 +1142,290 @@ abstract class AppLocalizations {
/// No description provided for @reflectionAction. /// No description provided for @reflectionAction.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Close the loop with one sentence'** /// **'Write one sentence to close today'**
String get reflectionAction; String get reflectionAction;
/// No description provided for @growthTitle. /// No description provided for @nextActionTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Growth'** /// **'Next action'**
String get growthTitle; String get nextActionTitle;
/// No description provided for @growthFlowerOpen. /// No description provided for @nextActionSetFocusTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Today\'s flower is open'** /// **'Write today\'s focus'**
String get growthFlowerOpen; String get nextActionSetFocusTitle;
/// No description provided for @growthLocked. /// No description provided for @nextActionSetFocusBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Unlocked after reflection'** /// **'One short line is enough. You can adjust it later.'**
String get growthLocked; String get nextActionSetFocusBody;
/// No description provided for @sevenDayGrowth. /// No description provided for @nextActionStartFocusTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'7-day growth'** /// **'Start 10 minutes'**
String get sevenDayGrowth; String get nextActionStartFocusTitle;
/// No description provided for @sevenDayGrowthSubtitle. /// No description provided for @nextActionStartFocusBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Small actions become visible over time.'** /// **'Writing the action is optional. Start first.'**
String get sevenDayGrowthSubtitle; String get nextActionStartFocusBody;
/// No description provided for @growthSeed. /// No description provided for @nextActionStartActionBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'seed'** /// **'Move this forward in one small session, then leave one progress note.'**
String get growthSeed; String get nextActionStartActionBody;
/// No description provided for @growthSprout. /// No description provided for @nextActionHabitsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'sprout'** /// **'Finish one small habit'**
String get growthSprout; String get nextActionHabitsTitle;
/// No description provided for @growthLeaves. /// No description provided for @nextActionHabitsBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'leaves'** /// **'Complete one repeatable step while it is still easy.'**
String get growthLeaves; String get nextActionHabitsBody;
/// No description provided for @growthFlower. /// No description provided for @nextActionReflectTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'flower'** /// **'Write a quick note'**
String get growthFlower; String get nextActionReflectTitle;
/// No description provided for @growthDaySemantic. /// No description provided for @nextActionReflectBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'{date}, {stage}'** /// **'Record what moved forward so the next start is easier.'**
String growthDaySemantic(Object date, Object stage); String get nextActionReflectBody;
/// No description provided for @growthSeedSemantic. /// No description provided for @nextActionCompleteTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'A seed waiting for today\'s intention'** /// **'Today moved forward'**
String get growthSeedSemantic; String get nextActionCompleteTitle;
/// No description provided for @growthSproutSemantic. /// No description provided for @nextActionCompleteBody.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'A sprout growing from today\'s intention'** /// **'Your note is saved. Continue only if it still feels useful.'**
String get growthSproutSemantic; String get nextActionCompleteBody;
/// No description provided for @growthLeavesSemantic. /// No description provided for @nextActionContinueFocus.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'A young plant growing with today\'s progress'** /// **'Focus {minutes} min again'**
String get growthLeavesSemantic; String nextActionContinueFocus(int minutes);
/// No description provided for @growthFlowerSemantic. /// No description provided for @habitTemplatesTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'A flower marking a complete daily cycle'** /// **'Useful starts'**
String get growthFlowerSemantic; String get habitTemplatesTitle;
/// No description provided for @habitTemplatesBody.
///
/// In en, this message translates to:
/// **'Add one tiny repeatable action.'**
String get habitTemplatesBody;
/// No description provided for @habitTemplateStretch.
///
/// In en, this message translates to:
/// **'2 min stretch'**
String get habitTemplateStretch;
/// No description provided for @habitTemplateRead.
///
/// In en, this message translates to:
/// **'Read 10 pages'**
String get habitTemplateRead;
/// No description provided for @habitTemplateWalk.
///
/// In en, this message translates to:
/// **'Walk outside'**
String get habitTemplateWalk;
/// No description provided for @habitTemplateNoScroll.
///
/// In en, this message translates to:
/// **'No-scroll lunch'**
String get habitTemplateNoScroll;
/// No description provided for @languageDetail.
///
/// In en, this message translates to:
/// **'Interface language'**
String get languageDetail;
/// No description provided for @recordsAndPrivacyTitle.
///
/// In en, this message translates to:
/// **'Records & privacy'**
String get recordsAndPrivacyTitle;
/// No description provided for @recordsAndPrivacyBody.
///
/// In en, this message translates to:
/// **'Your history stays local. Keep, reset, or delete it when needed.'**
String get recordsAndPrivacyBody;
/// No description provided for @welcomeTrustLabel.
///
/// In en, this message translates to:
/// **'TODAY'**
String get welcomeTrustLabel;
/// No description provided for @welcomeTitle.
///
/// In en, this message translates to:
/// **'Plant just one thing each day.'**
String get welcomeTitle;
/// No description provided for @welcomeBody.
///
/// In en, this message translates to:
/// **'Start with today\'s most important thing.'**
String get welcomeBody;
/// No description provided for @welcomeNext.
///
/// In en, this message translates to:
/// **'The next step starts here.'**
String get welcomeNext;
/// No description provided for @onboardingFocusBadge.
///
/// In en, this message translates to:
/// **'FOCUS'**
String get onboardingFocusBadge;
/// No description provided for @onboardingFocusTitle.
///
/// In en, this message translates to:
/// **'Protect 10 minutes first.'**
String get onboardingFocusTitle;
/// No description provided for @onboardingFocusBody.
///
/// In en, this message translates to:
/// **'It does not have to be long. Starting creates progress.'**
String get onboardingFocusBody;
/// No description provided for @onboardingFocusNext.
///
/// In en, this message translates to:
/// **'Keep this one thing in front of you while you focus.'**
String get onboardingFocusNext;
/// No description provided for @onboardingReflectBadge.
///
/// In en, this message translates to:
/// **'NOTE'**
String get onboardingReflectBadge;
/// No description provided for @onboardingReflectTitle.
///
/// In en, this message translates to:
/// **'Leave one progress note.'**
String get onboardingReflectTitle;
/// No description provided for @onboardingReflectBody.
///
/// In en, this message translates to:
/// **'When you finish, write one line so you know where to continue next time.'**
String get onboardingReflectBody;
/// No description provided for @onboardingReflectNext.
///
/// In en, this message translates to:
/// **'Now set today\'s focus.'**
String get onboardingReflectNext;
/// No description provided for @onboardingNext.
///
/// In en, this message translates to:
/// **'Continue'**
String get onboardingNext;
/// No description provided for @welcomeStart.
///
/// In en, this message translates to:
/// **'Set today\'s focus'**
String get welcomeStart;
/// No description provided for @welcomeSkip.
///
/// In en, this message translates to:
/// **'Skip'**
String get welcomeSkip;
/// No description provided for @landingTitle.
///
/// In en, this message translates to:
/// **'Start with 10 minutes.'**
String get landingTitle;
/// No description provided for @landingBody.
///
/// In en, this message translates to:
/// **'Writing is optional. Start first.'**
String get landingBody;
/// No description provided for @landingFocusLabel.
///
/// In en, this message translates to:
/// **'What do you want to do first?'**
String get landingFocusLabel;
/// No description provided for @landingFocusEntry.
///
/// In en, this message translates to:
/// **'Write down what you will do'**
String get landingFocusEntry;
/// No description provided for @landingFocusHint.
///
/// In en, this message translates to:
/// **'Example: finish the launch checklist'**
String get landingFocusHint;
/// No description provided for @customFocusDuration.
///
/// In en, this message translates to:
/// **'Custom length'**
String get customFocusDuration;
/// No description provided for @customFocusDurationHint.
///
/// In en, this message translates to:
/// **'Enter 1-180 minutes'**
String get customFocusDurationHint;
/// No description provided for @minutesInputLabel.
///
/// In en, this message translates to:
/// **'Minutes'**
String get minutesInputLabel;
/// No description provided for @landingStart.
///
/// In en, this message translates to:
/// **'Start 10 minutes'**
String get landingStart;
/// No description provided for @landingSkip.
///
/// In en, this message translates to:
/// **'Enter Today'**
String get landingSkip;
/// No description provided for @defaultHabitPlanTask. /// No description provided for @defaultHabitPlanTask.
/// ///
@@ -1094,7 +1436,7 @@ abstract class AppLocalizations {
/// No description provided for @defaultHabitFocus. /// No description provided for @defaultHabitFocus.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Focus for 25 minutes'** /// **'Focus for 10 minutes'**
String get defaultHabitFocus; String get defaultHabitFocus;
/// No description provided for @defaultHabitReflect. /// No description provided for @defaultHabitReflect.
+231 -56
View File
@@ -24,7 +24,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get navToday => 'Today'; String get navToday => 'Today';
@override @override
String get navJourney => 'Journey'; String get navRecords => 'Records';
@override @override
String get navMe => 'Me'; String get navMe => 'Me';
@@ -36,7 +36,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get couldNotOpenApp => 'Could not open Focus Seed.'; String get couldNotOpenApp => 'Could not open Focus Seed.';
@override @override
String get openFailed => 'Your local garden could not be opened.'; String get loadingLocalData => 'Loading local data';
@override
String get openFailed => 'Your local data could not be opened.';
@override @override
String get saveFailed => 'That change could not be saved. Please try again.'; String get saveFailed => 'That change could not be saved. Please try again.';
@@ -80,7 +83,18 @@ class AppLocalizationsEn extends AppLocalizations {
String get timerCompleted => 'Session complete'; String get timerCompleted => 'Session complete';
@override @override
String get reflectOneMinute => 'Reflect for 1 min'; 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 @override
String get startAnotherSession => 'Start another session'; String get startAnotherSession => 'Start another session';
@@ -94,6 +108,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get end => 'End'; String get end => 'End';
@override
String get completeSession => 'Complete session';
@override
String get continueSession => 'Continue session';
@override @override
String get resume => 'Resume'; String get resume => 'Resume';
@@ -123,7 +143,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get add => 'Add'; String get add => 'Add';
@override @override
String get emptyHabitPrompt => 'Add one repeatable habit.'; String get emptyHabitPrompt => 'Keep this optional and small.';
@override @override
String viewAllHabits(int count) { String viewAllHabits(int count) {
@@ -167,7 +187,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String dailyStepsComplete(int completed) { String dailyStepsComplete(int completed) {
return '$completed/4 daily steps complete'; return '$completed/3 daily steps complete';
} }
@override @override
@@ -180,13 +200,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get delete => 'Delete'; String get delete => 'Delete';
@override @override
String get habitsTitle => 'Habits'; String get habitsTitle => 'Supporting habits';
@override @override
String get habitsSubtitle => 'Keep it small and repeatable.'; String get habitsSubtitle => 'Optional repeatable steps. Today\'s focus still comes first.';
@override @override
String get emptyHabitsTitle => 'Add a small habit'; String get emptyHabitsTitle => 'Add a supporting habit';
@override @override
String get addHabitTitle => 'Add habit'; String get addHabitTitle => 'Add habit';
@@ -204,10 +224,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get save => 'Save'; String get save => 'Save';
@override @override
String get setIntentionTitle => 'Set intention'; String get textEntryRequired => 'Write a short line first.';
@override @override
String get editIntentionTitle => 'Edit intention'; String get setIntentionTitle => 'Set today\'s focus';
@override
String get editIntentionTitle => 'Edit today\'s focus';
@override @override
String get intentionHint => 'What matters most today?'; String get intentionHint => 'What matters most today?';
@@ -249,7 +272,7 @@ class AppLocalizationsEn extends AppLocalizations {
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.'; 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 @override
String get privacyBodyTwo => 'You can clear today\'s progress or delete all local data from Me at any time.'; String get privacyBodyTwo => 'You can clear today\'s progress or delete all local data from settings at any time.';
@override @override
String get reflectionEmptyError => 'Write one honest sentence.'; String get reflectionEmptyError => 'Write one honest sentence.';
@@ -258,19 +281,22 @@ class AppLocalizationsEn extends AppLocalizations {
String get reflectionSaveError => 'Could not save. Please try again.'; String get reflectionSaveError => 'Could not save. Please try again.';
@override @override
String get reflectionSheetTitle => 'How did it go?'; String get reflectionSheetTitle => 'Leave one progress note';
@override @override
String get reflectionSheetSubtitle => 'Keep what worked. Adjust one thing.'; String get reflectionSheetSubtitle => 'Save what moved forward so next time has a clear start.';
@override @override
String get reflectionHint => 'One useful sentence is enough.'; String get reflectionHint => 'What moved forward?';
@override
String get reflectionMoodLabel => 'Mood is optional';
@override @override
String get savingLabel => 'Saving'; String get savingLabel => 'Saving';
@override @override
String get saveReflection => 'Save reflection'; String get saveReflection => 'Save note';
@override @override
String get moodCalm => 'Calm'; String get moodCalm => 'Calm';
@@ -285,10 +311,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get moodTired => 'Tired'; String get moodTired => 'Tired';
@override @override
String get meTitle => 'Your garden'; String get meTitle => 'Me';
@override @override
String get meSubtitle => 'Preferences and records stay on this device.'; String get meSubtitle => 'Manage your rhythm, appearance, and records.';
@override
String get appSettingsTitle => 'App settings';
@override @override
String get appearance => 'Theme'; String get appearance => 'Theme';
@@ -321,7 +350,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get preferencesTitle => 'Preferences'; String get preferencesTitle => 'Preferences';
@override @override
String get preferencesDetail => 'Make it feel right'; String get preferencesDetail => 'Theme, language, and default focus';
@override
String get defaultFocusDuration => 'Default focus';
@override
String get defaultFocusDurationDetail => 'Next session';
@override @override
String get localData => 'Local records'; String get localData => 'Local records';
@@ -330,10 +365,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get localDataDetail => 'Private on device'; String get localDataDetail => 'Private on device';
@override @override
String get personalGardenTitle => 'Your focus garden'; String get personalGardenTitle => 'My accumulation';
@override @override
String get personalGardenSubtitle => 'Every small session leaves a trace you can return to.'; String get personalGardenSubtitle => 'Your local action records, progress notes, and app controls stay on this device.';
@override @override
String get focusDataLabel => 'Focus'; String get focusDataLabel => 'Focus';
@@ -342,7 +377,16 @@ class AppLocalizationsEn extends AppLocalizations {
String get habitsDataLabel => 'Habits'; String get habitsDataLabel => 'Habits';
@override @override
String get notesDataLabel => 'Notes'; String get notesDataLabel => 'Progress';
@override
String get totalActionTimeLabel => 'Action time';
@override
String get recordsCountLabel => 'Records';
@override
String get progressNotesCountLabel => 'Progress notes';
@override @override
String get clearToday => 'Clear today'; String get clearToday => 'Clear today';
@@ -366,39 +410,33 @@ class AppLocalizationsEn extends AppLocalizations {
String get localDataCleared => 'Local data deleted'; String get localDataCleared => 'Local data deleted';
@override @override
String get journeyTitle => 'Progress'; String get todayLoopLabel => 'TODAY\'S LOOP';
@override @override
String get journeyOverviewLabel => 'TODAY\'S LOOP'; String get todayLoopStartTitle => 'Start with one clear focus';
@override @override
String get journeyOverviewStartTitle => 'Start with one clear focus'; String get todayLoopStartBody => 'Pick the one result that matters today.';
@override @override
String get journeyOverviewStartBody => 'Pick the thing that would make today feel finished.'; String get todayLoopActiveTitle => 'Your day is taking shape';
@override @override
String get journeyOverviewActiveTitle => 'Your day is taking shape'; String get todayLoopActiveBody => 'Focus once, then leave one useful note.';
@override @override
String get journeyOverviewActiveBody => 'Keep the loop moving: focus, habits, then a short reflection.'; String get todayLoopDoneTitle => 'Progress saved';
@override @override
String get journeyOverviewDoneTitle => 'Loop complete'; String get todayLoopDoneBody => 'You moved today forward and left a clear place to continue.';
@override @override
String get journeyOverviewDoneBody => 'You left a clean record for future you.'; String todayLoopProgress(int completed) {
return '$completed/3 done';
@override
String journeyOverviewProgress(int completed) {
return '$completed/4 done';
} }
@override @override
String get reviewNow => 'Reflect now'; String get reviewNow => 'Write a note';
@override
String get journeyStatsTitle => '7-day rhythm';
@override @override
String get weekFocusLabel => 'Focus'; String get weekFocusLabel => 'Focus';
@@ -441,13 +479,51 @@ class AppLocalizationsEn extends AppLocalizations {
String get emptyFocusHistoryTitle => 'No focus sessions yet'; String get emptyFocusHistoryTitle => 'No focus sessions yet';
@override @override
String get emptyFocusHistoryBody => 'Start one session from Today and it will appear here.'; String get emptyFocusHistoryBody => 'Finish one focus session and it will appear here.';
@override @override
String get emptyNotesHistoryTitle => 'No notes yet'; String get emptyNotesHistoryTitle => 'No notes yet';
@override @override
String get emptyNotesHistoryBody => 'Save a short reflection after focus to make your progress visible.'; 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 @override
String get recentFocus => 'Recent focus'; String get recentFocus => 'Recent focus';
@@ -485,57 +561,156 @@ class AppLocalizationsEn extends AppLocalizations {
String get reflectionCaptured => 'Today\'s lesson is captured'; String get reflectionCaptured => 'Today\'s lesson is captured';
@override @override
String get reflectionAction => 'Close the loop with one sentence'; String get reflectionAction => 'Write one sentence to close today';
@override @override
String get growthTitle => 'Growth'; String get nextActionTitle => 'Next action';
@override @override
String get growthFlowerOpen => 'Today\'s flower is open'; String get nextActionSetFocusTitle => 'Write today\'s focus';
@override @override
String get growthLocked => 'Unlocked after reflection'; String get nextActionSetFocusBody => 'One short line is enough. You can adjust it later.';
@override @override
String get sevenDayGrowth => '7-day growth'; String get nextActionStartFocusTitle => 'Start 10 minutes';
@override @override
String get sevenDayGrowthSubtitle => 'Small actions become visible over time.'; String get nextActionStartFocusBody => 'Writing the action is optional. Start first.';
@override @override
String get growthSeed => 'seed'; String get nextActionStartActionBody => 'Move this forward in one small session, then leave one progress note.';
@override @override
String get growthSprout => 'sprout'; String get nextActionHabitsTitle => 'Finish one small habit';
@override @override
String get growthLeaves => 'leaves'; String get nextActionHabitsBody => 'Complete one repeatable step while it is still easy.';
@override @override
String get growthFlower => 'flower'; String get nextActionReflectTitle => 'Write a quick note';
@override @override
String growthDaySemantic(Object date, Object stage) { String get nextActionReflectBody => 'Record what moved forward so the next start is easier.';
return '$date, $stage';
@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 @override
String get growthSeedSemantic => 'A seed waiting for today\'s intention'; String get habitTemplatesTitle => 'Useful starts';
@override @override
String get growthSproutSemantic => 'A sprout growing from today\'s intention'; String get habitTemplatesBody => 'Add one tiny repeatable action.';
@override @override
String get growthLeavesSemantic => 'A young plant growing with today\'s progress'; String get habitTemplateStretch => '2 min stretch';
@override @override
String get growthFlowerSemantic => 'A flower marking a complete daily cycle'; 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 @override
String get defaultHabitPlanTask => 'Plan one clear task'; String get defaultHabitPlanTask => 'Plan one clear task';
@override @override
String get defaultHabitFocus => 'Focus for 25 minutes'; String get defaultHabitFocus => 'Focus for 10 minutes';
@override @override
String get defaultHabitReflect => 'Write one-line reflection'; String get defaultHabitReflect => 'Write one-line reflection';
+230 -55
View File
@@ -24,7 +24,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get navToday => '今日'; String get navToday => '今日';
@override @override
String get navJourney => '进展'; String get navRecords => '记录';
@override @override
String get navMe => '我的'; String get navMe => '我的';
@@ -35,6 +35,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get couldNotOpenApp => '无法打开 Focus Seed。'; String get couldNotOpenApp => '无法打开 Focus Seed。';
@override
String get loadingLocalData => '正在读取本地数据';
@override @override
String get openFailed => '本地数据打开失败。'; String get openFailed => '本地数据打开失败。';
@@ -80,7 +83,18 @@ class AppLocalizationsZh extends AppLocalizations {
String get timerCompleted => '已完成'; String get timerCompleted => '已完成';
@override @override
String get reflectOneMinute => '复盘 1 分钟'; String focusCurrentIntention(String intention) {
return '今日重点:$intention';
}
@override
String get focusNoIntentionTitle => '先开始这一段';
@override
String get reflectOneMinute => '写一句进展';
@override
String get startFocus => '开始专注';
@override @override
String get startAnotherSession => '再来一轮'; String get startAnotherSession => '再来一轮';
@@ -94,6 +108,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get end => '结束'; String get end => '结束';
@override
String get completeSession => '完成本轮';
@override
String get continueSession => '继续本轮';
@override @override
String get resume => '继续'; String get resume => '继续';
@@ -123,7 +143,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get add => '添加'; String get add => '添加';
@override @override
String get emptyHabitPrompt => '添加一个可重复的小习惯。'; String get emptyHabitPrompt => '保持可选,也保持很小。';
@override @override
String viewAllHabits(int count) { String viewAllHabits(int count) {
@@ -167,7 +187,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String dailyStepsComplete(int completed) { String dailyStepsComplete(int completed) {
return '今日完成 $completed/4'; return '今日完成 $completed/3';
} }
@override @override
@@ -180,13 +200,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get delete => '删除'; String get delete => '删除';
@override @override
String get habitsTitle => '习惯'; String get habitsTitle => '辅助习惯';
@override @override
String get habitsSubtitle => '少量、可重复。'; String get habitsSubtitle => '可选的重复动作,今日重点仍然放在前面。';
@override @override
String get emptyHabitsTitle => '添加小习惯'; String get emptyHabitsTitle => '添加一个辅助习惯';
@override @override
String get addHabitTitle => '添加习惯'; String get addHabitTitle => '添加习惯';
@@ -204,10 +224,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get save => '保存'; String get save => '保存';
@override @override
String get setIntentionTitle => '设置目标'; String get textEntryRequired => '先写一个短句。';
@override @override
String get editIntentionTitle => '编辑目标'; String get setIntentionTitle => '设置今日重点';
@override
String get editIntentionTitle => '编辑今日重点';
@override @override
String get intentionHint => '今天最重要的是什么?'; String get intentionHint => '今天最重要的是什么?';
@@ -249,7 +272,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get privacyBodyOne => 'Focus Seed 只在本机保存目标、习惯、专注记录、复盘和设置。当前版本没有登录、云同步、广告、分析 SDK 或付费 SDK。'; String get privacyBodyOne => 'Focus Seed 只在本机保存目标、习惯、专注记录、复盘和设置。当前版本没有登录、云同步、广告、分析 SDK 或付费 SDK。';
@override @override
String get privacyBodyTwo => '你可以随时清理今日进度,或删除本机的全部本地数据。'; String get privacyBodyTwo => '你可以随时在设置中清理今日进度,或删除本机的全部本地数据。';
@override @override
String get reflectionEmptyError => '写一句真实的总结。'; String get reflectionEmptyError => '写一句真实的总结。';
@@ -258,19 +281,22 @@ class AppLocalizationsZh extends AppLocalizations {
String get reflectionSaveError => '保存失败,请重试。'; String get reflectionSaveError => '保存失败,请重试。';
@override @override
String get reflectionSheetTitle => '复盘一下'; String get reflectionSheetTitle => '留下一句进展';
@override @override
String get reflectionSheetSubtitle => '保留有效的,调整一个点。'; String get reflectionSheetSubtitle => '记下刚推进了什么,下次就知道从哪里继续。';
@override @override
String get reflectionHint => '一句有用的话就够。'; String get reflectionHint => '刚才推进了什么?';
@override
String get reflectionMoodLabel => '状态可以稍后补';
@override @override
String get savingLabel => '保存中'; String get savingLabel => '保存中';
@override @override
String get saveReflection => '保存复盘'; String get saveReflection => '保存记录';
@override @override
String get moodCalm => '平静'; String get moodCalm => '平静';
@@ -285,10 +311,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get moodTired => '疲惫'; String get moodTired => '疲惫';
@override @override
String get meTitle => '我的花园'; String get meTitle => '我的';
@override @override
String get meSubtitle => '偏好和记录都只保存在本机。'; String get meSubtitle => '管理你的节奏、外观和记录。';
@override
String get appSettingsTitle => '应用设置';
@override @override
String get appearance => '主题'; String get appearance => '主题';
@@ -321,7 +350,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get preferencesTitle => '偏好'; String get preferencesTitle => '偏好';
@override @override
String get preferencesDetail => '让界面更顺手'; String get preferencesDetail => '主题、语言和默认专注';
@override
String get defaultFocusDuration => '默认专注';
@override
String get defaultFocusDurationDetail => '下一轮';
@override @override
String get localData => '本地记录'; String get localData => '本地记录';
@@ -330,10 +365,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get localDataDetail => '只在本机'; String get localDataDetail => '只在本机';
@override @override
String get personalGardenTitle => '你的专注花园'; String get personalGardenTitle => '我的累计';
@override @override
String get personalGardenSubtitle => '每一次小专注,都会留下可回看的痕迹。'; String get personalGardenSubtitle => '你的本地行动记录、进展和应用控制都保留在这台设备上。';
@override @override
String get focusDataLabel => '专注'; String get focusDataLabel => '专注';
@@ -342,7 +377,16 @@ class AppLocalizationsZh extends AppLocalizations {
String get habitsDataLabel => '习惯'; String get habitsDataLabel => '习惯';
@override @override
String get notesDataLabel => '记录'; String get notesDataLabel => '进展';
@override
String get totalActionTimeLabel => '行动时间';
@override
String get recordsCountLabel => '记录';
@override
String get progressNotesCountLabel => '进展';
@override @override
String get clearToday => '清理今天'; String get clearToday => '清理今天';
@@ -366,39 +410,33 @@ class AppLocalizationsZh extends AppLocalizations {
String get localDataCleared => '本地数据已删除'; String get localDataCleared => '本地数据已删除';
@override @override
String get journeyTitle => '进展'; String get todayLoopLabel => '今日闭环';
@override @override
String get journeyOverviewLabel => '今日闭环'; String get todayLoopStartTitle => '先定下一个重点';
@override @override
String get journeyOverviewStartTitle => '先定下一个重点'; String get todayLoopStartBody => '先写下今天最重要的一件事。';
@override @override
String get journeyOverviewStartBody => '写下今天最值得完成的那件事。'; String get todayLoopActiveTitle => '今天正在成形';
@override @override
String get journeyOverviewActiveTitle => '今天正在成形'; String get todayLoopActiveBody => '先完成一轮专注,再留一句有用的记录。';
@override @override
String get journeyOverviewActiveBody => '继续推进:专注、打卡,然后用一句话复盘。'; String get todayLoopDoneTitle => '进展已保存';
@override @override
String get journeyOverviewDoneTitle => '今日闭环完成'; String get todayLoopDoneBody => '今天已经推进了一点,下次也知道从哪里继续。';
@override @override
String get journeyOverviewDoneBody => '你已经给未来的自己留下了一条清晰记录。'; String todayLoopProgress(int completed) {
return '已完成 $completed/3';
@override
String journeyOverviewProgress(int completed) {
return '已完成 $completed/4';
} }
@override @override
String get reviewNow => '现在复盘'; String get reviewNow => '写一句记录';
@override
String get journeyStatsTitle => '7 日节奏';
@override @override
String get weekFocusLabel => '专注'; String get weekFocusLabel => '专注';
@@ -441,13 +479,51 @@ class AppLocalizationsZh extends AppLocalizations {
String get emptyFocusHistoryTitle => '还没有专注记录'; String get emptyFocusHistoryTitle => '还没有专注记录';
@override @override
String get emptyFocusHistoryBody => '从今日页开始一轮专注,完成后会出现在这里。'; String get emptyFocusHistoryBody => '完成一轮专注后,记录会出现在这里。';
@override @override
String get emptyNotesHistoryTitle => '还没有复盘'; String get emptyNotesHistoryTitle => '还没有复盘';
@override @override
String get emptyNotesHistoryBody => '专注后保存一句复盘,让进展更清晰。'; 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 @override
String get recentFocus => '最近专注'; String get recentFocus => '最近专注';
@@ -485,57 +561,156 @@ class AppLocalizationsZh extends AppLocalizations {
String get reflectionCaptured => '今日复盘已记录'; String get reflectionCaptured => '今日复盘已记录';
@override @override
String get reflectionAction => '用一句话收尾'; String get reflectionAction => '用一句话给今天收尾';
@override @override
String get growthTitle => '成长'; String get nextActionTitle => '下一步';
@override @override
String get growthFlowerOpen => '今日花朵已开放'; String get nextActionSetFocusTitle => '写下今天的重点';
@override @override
String get growthLocked => '复盘后解锁'; String get nextActionSetFocusBody => '一个短句就够了,之后还可以调整。';
@override @override
String get sevenDayGrowth => '7 日成长'; String get nextActionStartFocusTitle => '开始 10 分钟';
@override @override
String get sevenDayGrowthSubtitle => '连续行动会被看见。'; String get nextActionStartFocusBody => '写不写都可以,先开始。';
@override @override
String get growthSeed => '种子'; String get nextActionStartActionBody => '先推进一小段,完成后留一句进展。';
@override @override
String get growthSprout => '发芽'; String get nextActionHabitsTitle => '完成一个小习惯';
@override @override
String get growthLeaves => '生长'; String get nextActionHabitsBody => '趁着还顺手,先做掉一个可重复的小动作。';
@override @override
String get growthFlower => '开花'; String get nextActionReflectTitle => '写一句记录';
@override @override
String growthDaySemantic(Object date, Object stage) { String get nextActionReflectBody => '记下刚才推进了什么,下次更容易继续。';
return '$date,$stage';
@override
String get nextActionCompleteTitle => '今天已经推进了一点';
@override
String get nextActionCompleteBody => '进展已经留下,想继续时再来一段。';
@override
String nextActionContinueFocus(int minutes) {
return '再专注 $minutes 分钟';
} }
@override @override
String get growthSeedSemantic => '等待今日目标的种子'; String get habitTemplatesTitle => '可以直接开始';
@override @override
String get growthSproutSemantic => '目标已种下,正在发芽'; String get habitTemplatesBody => '添加一个很小但能重复的动作。';
@override @override
String get growthLeavesSemantic => '今日进度让植物生长'; String get habitTemplateStretch => '2 分钟拉伸';
@override @override
String get growthFlowerSemantic => '今日闭环完成,花朵开放'; 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 @override
String get defaultHabitPlanTask => '计划一个明确任务'; String get defaultHabitPlanTask => '计划一个明确任务';
@override @override
String get defaultHabitFocus => '专注 25 分钟'; String get defaultHabitFocus => '专注 10 分钟';
@override @override
String get defaultHabitReflect => '写一句复盘'; String get defaultHabitReflect => '写一句复盘';
-19
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../core/ambience.dart'; import '../core/ambience.dart';
import '../models/growth_stage.dart';
import '../models/mood.dart'; import '../models/mood.dart';
import 'generated/app_localizations.dart'; import 'generated/app_localizations.dart';
@@ -46,24 +45,6 @@ String localizedMood(AppLocalizations l10n, Mood mood) {
}; };
} }
String localizedGrowthStage(AppLocalizations l10n, GrowthStage stage) {
return switch (stage) {
GrowthStage.seed => l10n.growthSeed,
GrowthStage.sprout => l10n.growthSprout,
GrowthStage.leaves => l10n.growthLeaves,
GrowthStage.flower => l10n.growthFlower,
};
}
String localizedGrowthSemantic(AppLocalizations l10n, GrowthStage stage) {
return switch (stage) {
GrowthStage.seed => l10n.growthSeedSemantic,
GrowthStage.sprout => l10n.growthSproutSemantic,
GrowthStage.leaves => l10n.growthLeavesSemantic,
GrowthStage.flower => l10n.growthFlowerSemantic,
};
}
List<String> localizedStarterHabits(AppLocalizations l10n) { List<String> localizedStarterHabits(AppLocalizations l10n) {
return [ return [
l10n.defaultHabitPlanTask, l10n.defaultHabitPlanTask,
+126 -2
View File
@@ -1,5 +1,8 @@
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 {
@@ -9,6 +12,11 @@ class AppState {
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;
@@ -16,23 +24,101 @@ class AppState {
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 => bool get isFreshStart =>
habits.isEmpty && habits.isEmpty &&
sessions.isEmpty && sessions.isEmpty &&
reflections.isEmpty && reflections.isEmpty &&
anchor.isEmpty; anchor.isEmpty &&
actions.isEmpty &&
progressNotes.isEmpty;
String activeAnchor(String dateKey) { String activeAnchor(String dateKey) {
return anchorDateKey == dateKey ? anchor : ''; 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, 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,
@@ -40,6 +126,11 @@ class AppState {
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,
); );
} }
@@ -52,11 +143,19 @@ class AppState {
'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 anchor = (json['anchor'] as String?) ?? '';
final defaultMinutes = _validDefaultFocusMinutes(
json['defaultFocusMinutes'] as int?,
);
return AppState( return AppState(
anchor: anchor, anchor: anchor,
anchorDateKey: anchorDateKey:
@@ -69,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,
); );
} }
@@ -79,6 +186,16 @@ class AppState {
return value.whereType<Map<String, dynamic>>(); return value.whereType<Map<String, dynamic>>();
} }
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}) { static AppState starter({List<String>? defaultHabitTitles}) {
final now = DateTime.now(); final now = DateTime.now();
final titles = final titles =
@@ -86,7 +203,7 @@ class AppState {
? defaultHabitTitles! ? defaultHabitTitles!
: const [ : const [
'Plan one clear task', 'Plan one clear task',
'Focus for 25 minutes', 'Focus for 10 minutes',
'Write one-line reflection', 'Write one-line reflection',
]; ];
return AppState( return AppState(
@@ -117,6 +234,13 @@ class AppState {
); );
} }
static int _validDefaultFocusMinutes(int? value) {
if (value == null || !isFocusDurationPreset(value)) {
return defaultFocusDurationMinutes;
}
return value;
}
static String _currentDateKey([DateTime? value]) { static String _currentDateKey([DateTime? value]) {
final date = value ?? DateTime.now(); final date = value ?? DateTime.now();
return '${date.year.toString().padLeft(4, '0')}-' return '${date.year.toString().padLeft(4, '0')}-'
+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?,
); );
} }
} }
-29
View File
@@ -1,29 +0,0 @@
import '../core/date_keys.dart';
import 'app_state.dart';
enum GrowthStage { seed, sprout, leaves, flower }
GrowthStage growthStageFor(AppState state, String dateKey) {
if (state.reflections.any((entry) => entry.dateKey == dateKey)) {
return GrowthStage.flower;
}
final hasFocus = minutesForDate(state.sessions, dateKey) > 0;
final hasHabit = state.habits.any(
(habit) => habit.completedDates.contains(dateKey),
);
if (hasFocus || hasHabit) {
return GrowthStage.leaves;
}
return state.activeAnchor(dateKey).isEmpty
? GrowthStage.seed
: GrowthStage.sprout;
}
extension GrowthStagePresentation on GrowthStage {
String get assetPath => switch (this) {
GrowthStage.seed => 'assets/images/growth/growth-seed.png',
GrowthStage.sprout => 'assets/images/growth/growth-sprout.png',
GrowthStage.leaves => 'assets/images/growth/growth-leaves.png',
GrowthStage.flower => 'assets/images/growth/growth-flower.png',
};
}
-50
View File
@@ -1,50 +0,0 @@
import '../core/date_keys.dart';
import 'app_state.dart';
class JourneyStats {
const JourneyStats({
required this.currentStreakDays,
required this.weekFocusMinutes,
required this.activeDaysThisWeek,
required this.habitCompletionRate,
});
final int currentStreakDays;
final int weekFocusMinutes;
final int activeDaysThisWeek;
final double habitCompletionRate;
static JourneyStats fromState(AppState state) {
final dateKeys = lastSevenDateKeys();
final dateKeySet = dateKeys.toSet();
final weekFocusMinutes = dateKeys.fold<int>(
0,
(total, key) => total + minutesForDate(state.sessions, key),
);
final activeDays = dateKeys.where((key) => _hasProgress(state, key)).length;
final habitCompletions = state.habits.fold<int>(
0,
(total, habit) =>
total + habit.completedDates.where(dateKeySet.contains).length,
);
final habitOpportunities = state.habits.length * dateKeys.length;
return JourneyStats(
currentStreakDays: currentStreak(
state.habits,
state.sessions,
state.reflections,
),
weekFocusMinutes: weekFocusMinutes,
activeDaysThisWeek: activeDays,
habitCompletionRate:
habitOpportunities == 0 ? 0 : habitCompletions / habitOpportunities,
);
}
static bool _hasProgress(AppState state, String key) {
return state.habits.any((habit) => habit.completedDates.contains(key)) ||
state.sessions.any((session) => dateKey(session.completedAt) == key) ||
state.reflections.any((reflection) => reflection.dateKey == key);
}
}
+6 -6
View File
@@ -1,6 +1,5 @@
import '../core/date_keys.dart'; import '../core/date_keys.dart';
import 'app_state.dart'; import 'app_state.dart';
import 'growth_stage.dart';
class TodayProgress { class TodayProgress {
const TodayProgress({ const TodayProgress({
@@ -10,7 +9,6 @@ class TodayProgress {
required this.completedHabits, required this.completedHabits,
required this.totalHabits, required this.totalHabits,
required this.hasReflection, required this.hasReflection,
required this.growthStage,
}); });
factory TodayProgress.fromState(AppState state, String dateKey) { factory TodayProgress.fromState(AppState state, String dateKey) {
@@ -23,8 +21,11 @@ class TodayProgress {
.where((habit) => habit.completedDates.contains(dateKey)) .where((habit) => habit.completedDates.contains(dateKey))
.length, .length,
totalHabits: state.habits.length, totalHabits: state.habits.length,
hasReflection: state.reflections.any((entry) => entry.dateKey == dateKey), hasReflection:
growthStage: growthStageFor(state, dateKey), state.reflections.any((entry) => entry.dateKey == dateKey) ||
state.progressNotes.any(
(entry) => entry.dateKey == dateKey && entry.hasContent,
),
); );
} }
@@ -34,16 +35,15 @@ class TodayProgress {
final int completedHabits; final int completedHabits;
final int totalHabits; final int totalHabits;
final bool hasReflection; final bool hasReflection;
final GrowthStage growthStage;
bool get hasIntention => intention.isNotEmpty; bool get hasIntention => intention.isNotEmpty;
bool get hasFocus => focusMinutes > 0; bool get hasFocus => focusMinutes > 0;
bool get hasHabits => completedHabits > 0; bool get hasHabits => completedHabits > 0;
bool get isLoopClosed => hasIntention && hasFocus && hasReflection;
int get completedSteps => int get completedSteps =>
[ [
hasIntention, hasIntention,
hasFocus, hasFocus,
hasHabits,
hasReflection, hasReflection,
].where((complete) => complete).length; ].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 {}
}
+438 -134
View File
@@ -1,122 +1,262 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app/app_theme.dart';
import '../l10n/l10n.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 {
var value = initialValue; final l10n = context.l10n;
await showDialog<void>( await showTextEntrySheet(
context: context, context: context,
builder: title: initialValue.isEmpty ? l10n.addHabitTitle : l10n.renameHabitTitle,
(context) => AlertDialog( hintText: l10n.habitHint,
title: Text(
initialValue.isEmpty
? context.l10n.addHabitTitle
: context.l10n.renameHabitTitle,
),
content: TextFormField(
initialValue: initialValue, initialValue: initialValue,
autofocus: true, icon: Icons.add_task_rounded,
textCapitalization: TextCapitalization.sentences, accentBuilder: (context) => FocusSeedVisuals.of(context).habit,
decoration: InputDecoration( onSubmit: onSubmit,
border: const OutlineInputBorder(),
hintText: context.l10n.habitHint,
),
onChanged: (nextValue) => value = nextValue,
onFieldSubmitted: (value) {
onSubmit(value);
Navigator.pop(context);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onSubmit(value);
Navigator.pop(context);
},
child: Text(context.l10n.save),
),
],
),
); );
} }
Future<void> showIntentionDialog( Future<void> showIntentionDialog(
BuildContext context, BuildContext context,
ValueChanged<String> onSubmit, { Future<bool> Function(String value) onSubmit, {
String initialValue = '', String initialValue = '',
}) async { }) async {
var value = initialValue; final l10n = context.l10n;
await showDialog<void>( await showTextEntrySheet(
context: context, context: context,
builder: title:
(context) => AlertDialog( initialValue.isEmpty ? l10n.setIntentionTitle : l10n.editIntentionTitle,
title: Text( hintText: l10n.intentionHint,
initialValue.isEmpty
? context.l10n.setIntentionTitle
: context.l10n.editIntentionTitle,
),
content: TextFormField(
initialValue: initialValue, initialValue: initialValue,
autofocus: true, icon: Icons.spa_rounded,
maxLength: 90, maxLength: 90,
maxLines: 3, maxLines: 3,
accentBuilder: (context) => FocusSeedVisuals.of(context).action,
onSubmit: onSubmit,
);
}
Future<void> showTextEntrySheet({
required BuildContext context,
required String title,
required String hintText,
required Future<bool> Function(String value) onSubmit,
required IconData icon,
required Color Function(BuildContext context) accentBuilder,
String initialValue = '',
int? maxLength,
int minLines = 1,
int maxLines = 1,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder:
(context) => _TextEntrySheet(
title: title,
hintText: hintText,
initialValue: initialValue,
maxLength: maxLength,
minLines: minLines,
maxLines: maxLines,
icon: icon,
accent: accentBuilder(context),
onSubmit: onSubmit,
),
);
}
class _TextEntrySheet extends StatefulWidget {
const _TextEntrySheet({
required this.title,
required this.hintText,
required this.initialValue,
required this.icon,
required this.accent,
required this.onSubmit,
this.maxLength,
this.minLines = 1,
this.maxLines = 1,
});
final String title;
final String hintText;
final String initialValue;
final IconData icon;
final Color accent;
final Future<bool> Function(String value) onSubmit;
final int? maxLength;
final int minLines;
final int maxLines;
@override
State<_TextEntrySheet> createState() => _TextEntrySheetState();
}
class _TextEntrySheetState extends State<_TextEntrySheet> {
late final TextEditingController _controller;
bool _saving = false;
String? _errorText;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_saving) {
return;
}
final value = _controller.text.trim();
if (value.isEmpty) {
setState(() => _errorText = context.l10n.textEntryRequired);
return;
}
setState(() {
_saving = true;
_errorText = null;
});
final saved = await widget.onSubmit(value);
if (!mounted) {
return;
}
if (saved) {
Navigator.pop(context);
return;
}
setState(() {
_saving = false;
_errorText = context.l10n.saveFailed;
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final foreground =
ThemeData.estimateBrightnessForColor(widget.accent) == Brightness.dark
? Colors.white
: scheme.onSurface;
return AppBottomSheetScaffold(
title: widget.title,
icon: widget.icon,
accent: widget.accent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SurfacePanel(
radius: 18,
tone: SurfacePanelTone.paper,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: TextField(
controller: _controller,
autofocus: true,
maxLength: widget.maxLength,
minLines: widget.minLines,
maxLines: widget.maxLines,
textCapitalization: TextCapitalization.sentences, textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(hintText: context.l10n.intentionHint), textInputAction:
onChanged: (nextValue) => value = nextValue, widget.maxLines == 1
onFieldSubmitted: (value) { ? TextInputAction.done
onSubmit(value); : TextInputAction.newline,
Navigator.pop(context); onChanged: (_) {
if (_errorText == null) {
setState(() {});
return;
}
setState(() => _errorText = null);
}, },
onSubmitted: (_) {
if (widget.maxLines == 1) {
unawaited(_submit());
}
},
decoration: InputDecoration(
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: widget.hintText,
errorText: _errorText,
), ),
actions: [ ),
TextButton( ),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel), child: Text(l10n.cancel),
), ),
FilledButton( ),
onPressed: () { const SizedBox(width: 10),
onSubmit(value); Expanded(
Navigator.pop(context); flex: 2,
}, child: FilledButton(
child: Text(context.l10n.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),
),
),
],
), ),
], ],
), ),
); );
}
} }
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: Text(context.l10n.deleteHabitTitle), confirmLabel: context.l10n.delete,
content: Text(context.l10n.deleteHabitBody), icon: Icons.delete_outline_rounded,
actions: [ accentBuilder: (context) => Theme.of(context).colorScheme.error,
TextButton( onConfirm: onConfirm,
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: Text(context.l10n.delete),
),
],
),
); );
} }
@@ -124,26 +264,14 @@ Future<void> confirmClearToday(
BuildContext context, BuildContext context,
VoidCallback onConfirm, VoidCallback onConfirm,
) async { ) async {
await showDialog<void>( await showConfirmSheet(
context: context, context: context,
builder: title: context.l10n.clearTodayDataTitle,
(context) => AlertDialog( body: context.l10n.clearTodayDataBody,
title: Text(context.l10n.clearTodayDataTitle), confirmLabel: context.l10n.clearToday,
content: Text(context.l10n.clearTodayDataBody), icon: Icons.refresh_rounded,
actions: [ accentBuilder: (context) => FocusSeedVisuals.of(context).warning,
TextButton( onConfirm: onConfirm,
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.cancel),
),
FilledButton(
onPressed: () {
onConfirm();
Navigator.pop(context);
},
child: Text(context.l10n.clearToday),
),
],
),
); );
} }
@@ -151,60 +279,236 @@ 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: Text(context.l10n.clearAllDataTitle), title: title,
content: Text(context.l10n.clearAllDataBody), body: body,
actions: [ confirmLabel: confirmLabel,
TextButton( icon: icon,
onPressed: () => Navigator.pop(context), accent: accentBuilder(context),
child: Text(context.l10n.cancel), onConfirm: onConfirm,
), ),
FilledButton( );
style: FilledButton.styleFrom( }
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError, 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,
), ),
onPressed: () { child: Text(
onConfirm(); body,
Navigator.pop(context); style: theme.textTheme.bodyMedium?.copyWith(
}, color: scheme.onSurface,
child: Text(context.l10n.deleteAll), height: 1.42,
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,
accent: FocusSeedVisuals.of(context).privacy,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( _PrivacyPoint(
context.l10n.privacyTitle, icon: Icons.phone_android_rounded,
style: const TextStyle( text: context.l10n.privacyBodyOne,
fontSize: 22,
fontWeight: FontWeight.w800,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(context.l10n.privacyBodyOne), _PrivacyPoint(
const SizedBox(height: 12), icon: Icons.cleaning_services_rounded,
Text(context.l10n.privacyBodyTwo), text: context.l10n.privacyBodyTwo,
),
], ],
), ),
), ),
); );
} }
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),
],
);
},
);
}
} }
-145
View File
@@ -1,145 +0,0 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
class FocusSeedHeader extends StatelessWidget {
const FocusSeedHeader({
required this.title,
required this.subtitle,
this.showBrand = true,
this.isSaving = false,
this.trailing,
super.key,
});
final String title;
final String subtitle;
final bool showBrand;
final bool isSaving;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showBrand) ...[
LayoutBuilder(
builder: (context, constraints) {
final textScale =
MediaQuery.textScalerOf(context).scale(14) / 14;
final compact = constraints.maxWidth < 340 || textScale > 1.3;
return Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(
alpha: 0.12,
),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.spa_rounded,
color: theme.colorScheme.primary,
size: 21,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.brandName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child:
isSaving
? _SaveStatus(
key: const ValueKey('saving'),
icon: Icons.sync_rounded,
label: l10n.saving,
compact: compact,
)
: _SaveStatus(
key: const ValueKey('saved'),
icon: Icons.smartphone_outlined,
label: l10n.local,
compact: compact,
),
),
if (trailing != null) ...[
const SizedBox(width: 4),
trailing!,
],
],
);
},
),
const SizedBox(height: 12),
],
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(fontSize: 24),
),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class _SaveStatus extends StatelessWidget {
const _SaveStatus({
required this.icon,
required this.label,
required this.compact,
super.key,
});
final IconData icon;
final String label;
final bool compact;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Semantics(
label: label,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: theme.colorScheme.primary),
if (!compact) ...[
const SizedBox(width: 4),
Text(label, style: theme.textTheme.labelSmall),
],
],
),
),
);
}
}
-72
View File
@@ -1,72 +0,0 @@
import 'package:flutter/material.dart';
import '../app/app_theme.dart';
import '../l10n/l10n.dart';
import '../models/growth_stage.dart';
class GrowthObject extends StatelessWidget {
const GrowthObject({
required this.stage,
this.size = 148,
this.showAmbientHalo = true,
super.key,
});
final GrowthStage stage;
final double size;
final bool showAmbientHalo;
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.of(context).disableAnimations;
final visuals = FocusSeedVisuals.of(context);
final image = Image.asset(
stage.assetPath,
key: ValueKey(stage),
width: size,
height: size,
fit: BoxFit.contain,
gaplessPlayback: true,
semanticLabel: localizedGrowthSemantic(context.l10n, stage),
);
return SizedBox.square(
dimension: size,
child: Stack(
alignment: Alignment.center,
children: [
if (showAmbientHalo)
Container(
width: size * 0.76,
height: size * 0.76,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: visuals.ambientTint,
),
),
AnimatedSwitcher(
duration:
reduceMotion
? Duration.zero
: const Duration(milliseconds: 420),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
if (reduceMotion) {
return FadeTransition(opacity: animation, child: child);
}
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.94, end: 1.0).animate(animation),
child: child,
),
);
},
child: image,
),
],
),
);
}
}
+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;
} }
} }
-314
View File
@@ -1,314 +0,0 @@
import 'package:flutter/material.dart';
enum SeedIllustrationTone { garden, archive, data }
class SeedIllustration extends StatelessWidget {
const SeedIllustration({
this.tone = SeedIllustrationTone.garden,
this.size = 118,
this.hero = false,
super.key,
});
final SeedIllustrationTone tone;
final double size;
final bool hero;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final accent = switch (tone) {
SeedIllustrationTone.garden => scheme.primary,
SeedIllustrationTone.archive => scheme.secondary,
SeedIllustrationTone.data => scheme.tertiary,
};
final softAccent = accent.withValues(alpha: 0.18);
return ExcludeSemantics(
child: SizedBox.square(
dimension: size,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
softAccent.withValues(alpha: hero ? 0.92 : 1),
scheme.surfaceContainerHigh.withValues(alpha: 0.28),
Colors.transparent,
],
),
),
),
),
if (hero)
Positioned.fill(
child: CustomPaint(
painter: _OrbitPainter(
color: accent.withValues(alpha: 0.30),
quietColor: scheme.outlineVariant.withValues(alpha: 0.30),
),
),
),
if (hero)
Positioned(
left: size * 0.08,
top: size * 0.08,
child: _Sparkle(size: size * 0.13, color: accent),
),
if (hero)
Positioned(
right: size * 0.14,
bottom: size * 0.12,
child: _Sparkle(size: size * 0.10, color: scheme.tertiary),
),
Positioned(
bottom: size * 0.16,
child: Container(
width: size * (hero ? 0.72 : 0.62),
height: size * (hero ? 0.31 : 0.28),
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(size),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: hero ? 0.16 : 0.10),
blurRadius: hero ? 26 : 18,
offset: Offset(0, hero ? 12 : 8),
),
],
),
),
),
Positioned(
top: size * (hero ? 0.15 : 0.18),
left: size * (hero ? 0.24 : 0.28),
child: Transform.rotate(
angle: -0.62,
child: _LeafBlob(
width: size * (hero ? 0.30 : 0.26),
height: size * (hero ? 0.48 : 0.42),
color: accent.withValues(alpha: 0.78),
),
),
),
Positioned(
top: size * (hero ? 0.12 : 0.16),
right: size * (hero ? 0.24 : 0.28),
child: Transform.rotate(
angle: 0.62,
child: _LeafBlob(
width: size * (hero ? 0.33 : 0.28),
height: size * (hero ? 0.52 : 0.45),
color: scheme.primaryContainer.withValues(alpha: 0.86),
),
),
),
if (hero)
Positioned(
top: size * 0.25,
right: size * 0.36,
child: Transform.rotate(
angle: 0.10,
child: _LeafBlob(
width: size * 0.18,
height: size * 0.30,
color: scheme.tertiary.withValues(alpha: 0.62),
),
),
),
Positioned(
top: size * 0.44,
child: Container(
width: size * (hero ? 0.09 : 0.08),
height: size * (hero ? 0.32 : 0.28),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(99),
),
),
),
Positioned(
bottom: size * 0.26,
child: Transform.rotate(
angle: 0.18,
child: Container(
width: size * (hero ? 0.21 : 0.18),
height: size * (hero ? 0.31 : 0.28),
decoration: BoxDecoration(
color: scheme.tertiaryContainer.withValues(alpha: 0.90),
borderRadius: BorderRadius.circular(99),
border: Border.all(
color: scheme.tertiary.withValues(alpha: 0.28),
),
boxShadow: [
BoxShadow(
color: accent.withValues(alpha: 0.22),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
),
),
),
Positioned(
right: size * 0.10,
top: size * 0.30,
child: _FloatingDot(size: size * 0.09, color: accent),
),
Positioned(
left: size * 0.12,
bottom: size * 0.34,
child: _FloatingDot(size: size * 0.065, color: scheme.secondary),
),
],
),
),
);
}
}
class _LeafBlob extends StatelessWidget {
const _LeafBlob({
required this.width,
required this.height,
required this.color,
});
final double width;
final double height;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(width),
topRight: Radius.circular(width * 0.35),
bottomLeft: Radius.circular(width * 0.35),
bottomRight: Radius.circular(width),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.10),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
);
}
}
class _FloatingDot extends StatelessWidget {
const _FloatingDot({required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: 0.42),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.22),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
);
}
}
class _Sparkle extends StatelessWidget {
const _Sparkle({required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: size,
child: CustomPaint(painter: _SparklePainter(color)),
);
}
}
class _SparklePainter extends CustomPainter {
const _SparklePainter(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color.withValues(alpha: 0.72);
final center = Offset(size.width / 2, size.height / 2);
final path =
Path()
..moveTo(center.dx, 0)
..quadraticBezierTo(
center.dx * 1.18,
center.dy * 0.78,
size.width,
center.dy,
)
..quadraticBezierTo(
center.dx * 1.18,
center.dy * 1.18,
center.dx,
size.height,
)
..quadraticBezierTo(center.dx * 0.78, center.dy * 1.18, 0, center.dy)
..quadraticBezierTo(center.dx * 0.78, center.dy * 0.78, center.dx, 0);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _SparklePainter oldDelegate) {
return oldDelegate.color != color;
}
}
class _OrbitPainter extends CustomPainter {
const _OrbitPainter({required this.color, required this.quietColor});
final Color color;
final Color quietColor;
@override
void paint(Canvas canvas, Size size) {
final rect =
Offset(size.width * 0.08, size.height * 0.12) &
Size(size.width * 0.84, size.height * 0.72);
final paint =
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.shortestSide * 0.018
..strokeCap = StrokeCap.round
..color = quietColor;
canvas.drawArc(rect, -0.12, 4.8, false, paint);
paint.color = color;
canvas.drawArc(rect, 2.8, 1.25, false, paint);
}
@override
bool shouldRepaint(covariant _OrbitPainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.quietColor != quietColor;
}
}
+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),
],
),
),
),
); );
} }
} }
+50 -14
View File
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app/app_theme.dart'; import '../app/app_theme.dart';
import 'pressable_scale.dart';
enum SurfacePanelTone { plain, paper, wash }
class SurfacePanel extends StatelessWidget { class SurfacePanel extends StatelessWidget {
const SurfacePanel({ const SurfacePanel({
@@ -9,6 +12,8 @@ class SurfacePanel extends StatelessWidget {
this.margin, this.margin,
this.color, this.color,
this.borderColor, this.borderColor,
this.tone = SurfacePanelTone.plain,
this.radius = 12,
this.elevated = false, this.elevated = false,
this.onTap, this.onTap,
super.key, super.key,
@@ -19,6 +24,8 @@ class SurfacePanel extends StatelessWidget {
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
final Color? color; final Color? color;
final Color? borderColor; final Color? borderColor;
final SurfacePanelTone tone;
final double radius;
final bool elevated; final bool elevated;
final VoidCallback? onTap; final VoidCallback? onTap;
@@ -26,25 +33,53 @@ class SurfacePanel extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final visuals = FocusSeedVisuals.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( final decoration = BoxDecoration(
color: color ?? theme.colorScheme.surfaceContainerLowest, color: effectiveColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(radius),
border: Border.all( border:
color: borderColor ?? theme.colorScheme.outlineVariant, effectiveBorderColor.a == 0
), ? null
boxShadow: : Border.all(color: effectiveBorderColor),
elevated boxShadow: [
? [
BoxShadow( BoxShadow(
color: visuals.shadow, color: visuals.shadow.withValues(
blurRadius: 16, alpha:
offset: const Offset(0, 5), theme.colorScheme.brightness == Brightness.dark
? elevated
? 0.22
: 0.11
: elevated
? 0.11
: 0.045,
), ),
] blurRadius: elevated ? 22 : 12,
: null, offset: Offset(0, elevated ? 8 : 4),
),
],
); );
final content = Padding(padding: padding, child: child); final content = Padding(padding: padding, child: child);
return Container( return PressableScale(
enabled: onTap != null,
child: Container(
margin: margin, margin: margin,
decoration: decoration, decoration: decoration,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -55,6 +90,7 @@ class SurfacePanel extends StatelessWidget {
color: Colors.transparent, color: Colors.transparent,
child: InkWell(onTap: onTap, child: content), child: InkWell(onTap: onTap, child: content),
), ),
),
); );
} }
} }
+2 -1
View File
@@ -69,7 +69,8 @@ flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/images/growth/ - 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:
+97
View File
@@ -12,6 +12,8 @@ import 'package:focus_seed/src/models/reflection_entry.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
@@ -77,4 +79,99 @@ void main() {
'reflection-yesterday', '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);
});
} }
+16 -1
View File
@@ -52,7 +52,13 @@ void main() {
), ),
], ],
sessions: [ sessions: [
FocusSession(id: 'session-1', minutes: 25, completedAt: now), FocusSession(
id: 'session-1',
minutes: 25,
actualSeconds: 90,
completedAt: now,
intention: 'Ship the local database',
),
], ],
reflections: [ reflections: [
ReflectionEntry( ReflectionEntry(
@@ -63,6 +69,7 @@ void main() {
createdAt: now.add(const Duration(minutes: 1)), createdAt: now.add(const Duration(minutes: 1)),
), ),
], ],
defaultFocusMinutes: 45,
); );
await store.save(state); await store.save(state);
@@ -72,7 +79,15 @@ void main() {
expect(loaded.anchorDateKey, '2026-06-12'); expect(loaded.anchorDateKey, '2026-06-12');
expect(loaded.habits.single.completedDates, {'2026-06-11', '2026-06-12'}); expect(loaded.habits.single.completedDates, {'2026-06-11', '2026-06-12'});
expect(loaded.sessions.single.minutes, 25); 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.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');
}, },
); );
-65
View File
@@ -1,65 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_seed/src/models/app_state.dart';
import 'package:focus_seed/src/models/focus_session.dart';
import 'package:focus_seed/src/models/growth_stage.dart';
import 'package:focus_seed/src/models/habit.dart';
import 'package:focus_seed/src/models/mood.dart';
import 'package:focus_seed/src/models/reflection_entry.dart';
void main() {
const key = '2026-06-11';
final date = DateTime(2026, 6, 11, 10);
test('growth stage follows real daily progress', () {
const seed = AppState(
anchor: '',
anchorDateKey: key,
habits: [],
sessions: [],
reflections: [],
);
expect(growthStageFor(seed, key), GrowthStage.seed);
final sprout = seed.copyWith(anchor: 'Ship one useful thing');
expect(growthStageFor(sprout, key), GrowthStage.sprout);
final leaves = sprout.copyWith(
sessions: [FocusSession(id: 'session-1', minutes: 25, completedAt: date)],
habits: [
Habit(
id: 'habit-1',
title: 'Plan one task',
createdAt: date,
completedDates: const {key},
),
],
);
expect(growthStageFor(leaves, key), GrowthStage.leaves);
final flower = leaves.copyWith(
reflections: [
ReflectionEntry(
id: 'reflection-1',
dateKey: key,
note: 'A smaller scope made the work clearer.',
mood: Mood.clear,
createdAt: date,
),
],
);
expect(growthStageFor(flower, key), GrowthStage.flower);
});
test('version one anchor data migrates to the current day', () {
final state = AppState.fromJson(const {
'version': 1,
'anchor': 'Legacy intention',
'habits': <Object>[],
'sessions': <Object>[],
'reflections': <Object>[],
});
expect(state.anchorDateKey, isNotEmpty);
expect(state.toJson()['version'], 2);
});
}
+388 -62
View File
@@ -1,9 +1,13 @@
import 'dart:io';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter/material.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/focus_seed_store.dart';
import 'package:focus_seed/src/data/local/focus_seed_database.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() {
@@ -16,45 +20,352 @@ void main() {
expect(focusSeedDefaultLocale(), const Locale('zh')); expect(focusSeedDefaultLocale(), const Locale('zh'));
}); });
testWidgets('Today guides the user from intention to focus', (tester) async { 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')); await pumpFocusSeedApp(tester, locale: const Locale('en'));
expect(find.text('Focus Seed'), findsOneWidget); expect(find.text('Focus Seed'), findsOneWidget);
expect(find.text("TODAY'S FOCUS"), findsOneWidget); expect(find.text('Start 10 min'), findsWidgets);
expect(find.text("Set today's focus"), findsOneWidget); expect(find.text('Name today\'s focus'), findsOneWidget);
expect(find.text('Pick a focus plan'), findsNothing);
expect(find.text('Deep work sprint'), findsNothing);
});
await tester.tap(find.text("Name today's focus")); 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.pumpAndSettle();
await tester.enterText( await tester.enterText(
find.byType(TextField), find.byType(TextField),
'Finish the launch checklist', 'Finish the launch checklist',
); );
await tester.ensureVisible(find.text('Start 10 minutes'));
await tester.pumpAndSettle();
await tester.tap(find.text('Start 10 minutes'));
await tester.pumpAndSettle();
expect(
find.text('Today\'s focus: Finish the launch checklist'),
findsOneWidget,
);
expect(find.text('10 min'), findsWidgets);
});
testWidgets('finished onboarding restores landing until it is completed', (
tester,
) async {
SharedPreferences.setMockInitialValues({
'focus_seed_onboarding_complete': true,
'focus_seed_landing_setup_complete': false,
});
final database = FocusSeedDatabase(NativeDatabase.memory());
addTearDown(database.close);
await tester.pumpWidget(
FocusSeedApp(
locale: const Locale('en'),
focusSeedStore: FocusSeedStore(database: database),
),
);
await tester.pumpAndSettle();
expect(find.text('Start with 10 minutes.'), findsOneWidget);
expect(find.text('Focus Seed'), findsNothing);
await tester.ensureVisible(find.text('Enter Today'));
await tester.pumpAndSettle();
await tester.tap(find.text('Enter Today'));
await tester.pumpAndSettle();
expect(find.text('Focus Seed'), findsOneWidget);
expect(find.text('Start with 10 minutes.'), findsNothing);
await tester.pumpWidget(
FocusSeedApp(
locale: const Locale('en'),
focusSeedStore: FocusSeedStore(database: database),
),
);
await tester.pumpAndSettle();
expect(find.text('Focus Seed'), findsOneWidget);
expect(find.text('Start with 10 minutes.'), findsNothing);
});
testWidgets('bottom navigation exposes Focus, Records, and Me', (
tester,
) async {
await pumpFocusSeedApp(tester, locale: const Locale('en'));
await tester.tap(_navDestination('Focus'));
await tester.pumpAndSettle();
expect(find.text('Ready when you are'), findsOneWidget);
await tester.tap(_navDestination('Records'));
await tester.pumpAndSettle();
expect(find.text('Records'), findsWidgets);
expect(find.text('No progress records yet'), findsOneWidget);
await tester.tap(_navDestination('Me'));
await tester.pumpAndSettle();
expect(find.text('Me'), findsWidgets);
expect(find.text('Preferences'), findsWidgets);
await tester.tap(_navDestination('Today'));
await tester.pumpAndSettle();
expect(find.text('Name today\'s focus'), findsOneWidget);
});
testWidgets('V1 action loop records a session and capture', (tester) async {
await pumpFocusSeedApp(tester, locale: const Locale('en'));
final startButton = find.ancestor(
of: find.text('Start 10 min').last,
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
);
await tester.ensureVisible(startButton);
await tester.pumpAndSettle();
await tester.tap(startButton);
await tester.pumpAndSettle();
expect(find.text('Deep work in progress'), findsOneWidget);
await tester.tap(find.text('Pause'));
await tester.pumpAndSettle();
expect(find.text('Paused - your place is kept'), findsOneWidget);
await tester.tap(find.text('Resume'));
await tester.pumpAndSettle();
expect(find.text('Deep work in progress'), findsOneWidget);
await tester.tap(find.text('Complete session'));
await tester.pumpAndSettle();
expect(find.text('Session complete'), findsOneWidget);
expect(find.text('Write progress note'), findsOneWidget);
await tester.tap(find.text('Write progress note'));
await tester.pumpAndSettle();
expect(find.text('Leave one progress note'), findsOneWidget);
await tester.enterText(find.byType(TextField).last, 'Finished one pass.');
await tester.tap(find.text('Save note'));
await tester.pumpAndSettle();
await tester.tap(_navDestination('Records'));
await tester.pumpAndSettle();
expect(find.text('Records'), findsWidgets);
expect(find.text('Finished one pass.'), findsOneWidget);
expect(find.text('No progress records yet'), findsNothing);
});
testWidgets('Records can backfill reflection for a past daily record', (
tester,
) async {
final database = FocusSeedDatabase(NativeDatabase.memory());
addTearDown(database.close);
final store = FocusSeedStore(database: database);
final now = DateTime.now();
final yesterday = now.subtract(const Duration(days: 1));
await store.save(
AppState(
anchor: '',
anchorDateKey: '',
habits: const [],
sessions: [
FocusSession(
id: 'session-yesterday',
minutes: 10,
actualSeconds: 600,
completedAt: yesterday,
intention: 'Draft launch notes',
),
],
reflections: const [],
),
);
await tester.pumpWidget(
FocusSeedApp(
locale: const Locale('en'),
focusSeedStore: store,
showWelcomeOnFirstRun: false,
),
);
await tester.pumpAndSettle();
await tester.tap(_navDestination('Records'));
await tester.pumpAndSettle();
expect(find.text('Draft launch notes'), findsWidgets);
expect(find.text('No progress note yet'), findsWidgets);
expect(find.text('Write one sentence to close today'), findsOneWidget);
expect(find.text('Continue this'), findsNothing);
final reflectionButton = find.ancestor(
of: find.text('Write one sentence to close today'),
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
);
await tester.ensureVisible(reflectionButton);
await tester.pumpAndSettle();
await tester.tap(reflectionButton);
await tester.pumpAndSettle();
expect(find.text('Leave one progress note'), findsOneWidget);
expect(find.text('TODAY\'S FOCUS'), findsWidgets);
expect(find.text('Draft launch notes'), findsWidgets);
await tester.enterText(find.byType(TextField).last, 'Next: polish copy.');
final saveNoteButton = find.ancestor(
of: find.text('Save note'),
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
);
await tester.ensureVisible(saveNoteButton);
await tester.pumpAndSettle();
await tester.tap(saveNoteButton);
await tester.pumpAndSettle();
expect(find.text('Next: polish copy.'), findsOneWidget);
expect(find.text('Write one sentence to close today'), findsNothing);
expect(find.text('Continue this'), findsNothing);
});
testWidgets('settings manages local data without JSON tools', (tester) async {
await pumpFocusSeedApp(tester, locale: const Locale('en'));
await tester.tap(_navDestination('Me'));
await tester.pumpAndSettle();
var meScroll = find.byType(Scrollable).first;
expect(find.text('Personal rhythm'), findsNothing);
expect(find.text('Saved plans'), findsNothing);
await tester.scrollUntilVisible(
find.text('Theme, language, and default focus'),
180,
scrollable: meScroll,
);
final preferencesRow = find.ancestor(
of: find.text('Theme, language, and default focus'),
matching: find.byType(InkWell),
);
expect(preferencesRow, findsOneWidget);
await tester.tap(preferencesRow);
await tester.pumpAndSettle();
expect(find.text('Default focus'), findsOneWidget);
expect(find.text('10 min'), findsWidgets);
expect(find.text('1'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
expect(find.text('45'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add_rounded).last);
await tester.pumpAndSettle();
expect(find.text('Custom length'), findsOneWidget);
await tester.enterText(find.byType(TextField), '7');
await tester.tap(find.text('Save')); await tester.tap(find.text('Save'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('7 min'), findsOneWidget);
expect(find.text('Finish the launch checklist'), findsOneWidget); await tester.pageBack();
expect(find.text('Start 25 min'), findsOneWidget);
});
testWidgets('bottom navigation exposes Journey and Me', (tester) async {
await pumpFocusSeedApp(tester, locale: const Locale('en'));
await tester.tap(find.text('Journey'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Progress'), findsOneWidget); meScroll = find.byType(Scrollable).first;
await tester.scrollUntilVisible(
await tester.tap(find.text('Me')); find.text('Local records'),
await tester.pumpAndSettle(); 180,
expect(find.text('Your garden'), findsOneWidget); scrollable: meScroll,
}); );
testWidgets('Me manages local data without JSON tools', (tester) async {
await pumpFocusSeedApp(tester, locale: const Locale('en'));
await tester.tap(find.text('Me'));
await tester.pumpAndSettle();
final meScroll = find.byType(Scrollable).first;
expect(find.text('Local records'), findsOneWidget); expect(find.text('Local records'), findsOneWidget);
expect(find.text('Clear today'), findsOneWidget); expect(find.text('Clear today'), findsOneWidget);
await tester.scrollUntilVisible( await tester.scrollUntilVisible(
@@ -67,35 +378,17 @@ void main() {
expect(find.text('Import JSON'), findsNothing); expect(find.text('Import JSON'), findsNothing);
}); });
testWidgets('Journey opens history detail with empty states', (tester) async { testWidgets('Records shows empty focus and reflection states', (
tester,
) async {
await pumpFocusSeedApp(tester, locale: const Locale('en')); await pumpFocusSeedApp(tester, locale: const Locale('en'));
await tester.tap(find.text('Journey')); await tester.tap(_navDestination('Records'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final journeyScroll = find.byType(Scrollable).first; expect(find.text('Records'), findsWidgets);
await tester.scrollUntilVisible( expect(find.text('No progress records yet'), findsOneWidget);
find.text('7-day rhythm'), expect(find.text('Back to Today'), findsOneWidget);
180,
scrollable: journeyScroll,
);
expect(find.text('7-day rhythm'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('Record library'),
180,
scrollable: journeyScroll,
);
expect(find.text('Record library'), findsOneWidget);
await tester.tap(find.text('Record library'));
await tester.pumpAndSettle();
expect(find.text('History'), findsOneWidget);
expect(find.text('No focus sessions yet'), findsOneWidget);
await tester.tap(find.text('Notes'));
await tester.pumpAndSettle();
expect(find.text('No notes yet'), findsOneWidget);
}); });
testWidgets('Today remains usable at narrow width and 200 percent text', ( testWidgets('Today remains usable at narrow width and 200 percent text', (
@@ -121,16 +414,16 @@ void main() {
await pumpFocusSeedApp(tester); await pumpFocusSeedApp(tester);
expect(find.text('Focus Seed'), findsOneWidget); expect(find.text('Focus Seed'), findsOneWidget);
expect(find.text('\u4eca\u65e5\u91cd\u70b9'), findsOneWidget);
expect(find.text('\u8bbe\u7f6e\u4eca\u65e5\u91cd\u70b9'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('\u8ba1\u5212\u4e00\u4e2a\u660e\u786e\u4efb\u52a1'),
180,
);
expect( expect(
find.text('\u8ba1\u5212\u4e00\u4e2a\u660e\u786e\u4efb\u52a1'), find.text('\u5199\u4e0b\u4eca\u5929\u7684\u91cd\u70b9'),
findsOneWidget, 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', ( testWidgets('development language switch changes the active locale', (
@@ -138,15 +431,36 @@ void main() {
) async { ) async {
await pumpFocusSeedApp(tester); await pumpFocusSeedApp(tester);
await tester.tap(find.text('\u6211\u7684')); await tester.tap(_navDestination('\u6211\u7684'));
await tester.pumpAndSettle(); 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); expect(find.text('\u8bed\u8a00'), findsOneWidget);
await tester.tap(find.byIcon(Icons.language_rounded)); 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(); await tester.pumpAndSettle();
expect(find.text('Language'), findsOneWidget); expect(find.text('Language'), findsOneWidget);
expect(find.text('Development only'), findsOneWidget); expect(find.text('English'), findsOneWidget);
}); });
} }
@@ -157,7 +471,19 @@ Future<void> pumpFocusSeedApp(WidgetTester tester, {Locale? locale}) async {
FocusSeedApp( FocusSeedApp(
locale: locale, locale: locale,
focusSeedStore: FocusSeedStore(database: database), focusSeedStore: FocusSeedStore(database: database),
showWelcomeOnFirstRun: false,
), ),
); );
await tester.pumpAndSettle(); 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'));
}