首次远程推送
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
# Focus Seed
|
||||
|
||||
Focus Seed is a private, offline-first focus timer, habit tracker, and daily reflection app built with Flutter.
|
||||
Focus Seed is the current working name for a Flutter product evolving from a lightweight daily action loop into a personal growth and behavior intelligence system.
|
||||
|
||||
## V1 Product Loop
|
||||
Current implementation starts with local-first focus sessions, habits, reflection, records, and privacy controls. Product direction, PRD, and roadmap are governed by `docs/product-current-baseline.md`.
|
||||
|
||||
1. Choose one anchor task for today.
|
||||
2. Complete a focused session.
|
||||
3. Check off small habits.
|
||||
4. Save a one-minute reflection.
|
||||
5. Review the seven-day pulse.
|
||||
## Current MVP Loop
|
||||
|
||||
1. Choose or continue one meaningful action.
|
||||
2. Complete a focused/action session.
|
||||
3. Capture a short record.
|
||||
4. Review accumulated records.
|
||||
5. Grow toward calendar, goals, tags, trends, and user insight over time.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -26,7 +28,7 @@ See `launch/architecture.md` for the complete responsibility map.
|
||||
|
||||
## Privacy
|
||||
|
||||
Version 1.0.0 has no account system, server, advertising SDK, analytics SDK, billing SDK, or sensitive Android permissions. User data remains on the device unless the user explicitly exports it.
|
||||
The current implementation has no account system, server, advertising SDK, analytics SDK, billing SDK, or sensitive Android permissions. This is the current trust and launch posture, not a permanent product ceiling.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
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 |
@@ -1,5 +1,7 @@
|
||||
# Focus Seed 1.0.1 Architecture
|
||||
|
||||
Status: historical reference. The current architecture and navigation contract lives in `../../launch/architecture.md` and `../product-current-baseline.md`.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep the app offline-first and SDK-light. Flutter renders all product UI. `shared_preferences` remains the persistence adapter because the current dataset is one small versioned document and does not require relational queries, pagination, joins, or concurrent writers.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -31,14 +31,14 @@ This direction is global-friendly, feasible in Flutter, serious enough for produ
|
||||
|
||||
## Target App Structure
|
||||
|
||||
Preferred primary navigation:
|
||||
Current V1 primary navigation:
|
||||
|
||||
- Today: daily action hub.
|
||||
- Focus: immersive timer and active session.
|
||||
- Habits: habit list, completion, weekly rhythm.
|
||||
- Growth: reflection, history, trends, milestones.
|
||||
- Records: focus sessions and reflection history.
|
||||
- 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
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| Growth | Understand progress | Reflection entry or weekly summary | Reflect today / view timeline | Sessions, habits, records, trend charts | Timeline + cards; history can go to detail |
|
||||
| Settings | Adjust app and privacy | Local data, appearance, language | Change preference | Privacy, clear data, version | Compact grouped list |
|
||||
| Records | Review saved work | Focus/reflection history switch | Open Today when empty | Sessions, notes, simple trends | Timeline/list; detail pages are V2 |
|
||||
| Me | Adjust app and privacy | Local data summary | Change preference | Privacy, clear data, language | Compact grouped list |
|
||||
|
||||
## Visual System
|
||||
|
||||
Palette:
|
||||
|
||||
- Primary: mature green or teal for progress and focus.
|
||||
- Secondary: restrained blue/violet for reflection or insight.
|
||||
- Brand: deep ink for identity, headings, and logo text. Do not use brand as a catch-all accent.
|
||||
- Action: blue for primary buttons, navigation selection, and forward movement.
|
||||
- Focus: blue family for timer and active-session controls.
|
||||
- Habit: amber or warm accent for small repeatable actions.
|
||||
- Reflection: violet for notes, review, and insight.
|
||||
- Success: green for completion, checkmarks, and finished progress.
|
||||
- Privacy/local: green-teal for on-device trust indicators only.
|
||||
- Warm neutral surfaces instead of pure white where appropriate.
|
||||
- Semantic colors for success, warning, destructive, disabled, offline.
|
||||
- Warning/destructive/disabled/offline must remain separate semantic colors.
|
||||
- Dark mode must be designed, not inverted.
|
||||
|
||||
Typography:
|
||||
@@ -79,6 +83,14 @@ Motifs:
|
||||
- Use illustration in empty states, onboarding, growth, and rewards.
|
||||
- Do not decorate every card.
|
||||
|
||||
Asset ambition:
|
||||
|
||||
- Be more expressive with the logo, app icon, key illustration set, reward visuals, and store screenshots. These are allowed to carry stronger identity than the everyday UI.
|
||||
- Do not rely only on the plant metaphor. Explore sharper symbols such as focus rings, marked paths, light windows, paper cuts, modular tiles, timeline traces, and crafted seed/anchor hybrids.
|
||||
- Navigation and utility icons should stay readable and consistent, but they may use a custom symbolic grammar after the structure passes.
|
||||
- Illustrations should feel complete and production-worthy at their real display size. Avoid rough placeholder line art in polished previews.
|
||||
- Dense task surfaces stay restrained; onboarding, empty states, milestones, completion moments, and launch assets can be bolder.
|
||||
|
||||
## Interaction And Motion
|
||||
|
||||
Motion budget:
|
||||
@@ -87,6 +99,12 @@ Motion budget:
|
||||
- 15% spatial motion: tab/page/sheet/list transitions.
|
||||
- 5% reward: session complete, streak, milestone.
|
||||
|
||||
Motion ambition:
|
||||
|
||||
- Reward moments may be more memorable than the base UI: focus ring bloom, path lighting, notebook mark, seed-to-signal transition, or timeline pulse.
|
||||
- Keep bold motion rare, interruptible, and non-blocking. It should celebrate completion or clarify state, not decorate every tap.
|
||||
- Use Flutter-safe primitives first. Escalate to Rive/Lottie only for a signature reward or onboarding moment with a static fallback and reduced-motion behavior.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Tap feedback within 100 ms.
|
||||
@@ -127,9 +145,9 @@ Create or consolidate:
|
||||
|
||||
1. Create design, component, and motion foundations.
|
||||
2. Prototype Today and Focus first; review screenshots and interaction.
|
||||
3. Add the Habits screen as a real first-class surface.
|
||||
4. Rework Growth into reflection plus progress timeline.
|
||||
5. Move Settings out of primary navigation or make it compact.
|
||||
3. Keep Records and Me honest until Habits/Growth have enough product depth to become primary tabs.
|
||||
4. Add detail pages only when they have data writes, navigation targets, and clear user outcomes.
|
||||
5. Keep Settings compact and user-facing; avoid developer-tool language.
|
||||
6. Add empty/dense/error/offline/restored states.
|
||||
7. Verify light/dark, English/Chinese, small phone, large text.
|
||||
8. Build and test Android release after each large module.
|
||||
|
||||
@@ -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
|
||||
|
||||
Status: historical implementation/reference plan. Superseded for active product direction by `../product-current-baseline.md`.
|
||||
|
||||
## Decision Header
|
||||
|
||||
- Objective: move Focus Seed from a simple local MVP into a richer offline-first product with durable history, user-facing settings, and basic retention feedback.
|
||||
|
||||
+54
-60
@@ -1,6 +1,6 @@
|
||||
# Focus Seed Architecture
|
||||
|
||||
Status: refactored from single-file prototype into modular Flutter structure.
|
||||
Status: current release architecture for Focus Seed 1.0.2.
|
||||
|
||||
## Directory Layout
|
||||
|
||||
@@ -8,77 +8,71 @@ Status: refactored from single-file prototype into modular Flutter structure.
|
||||
lib/
|
||||
main.dart
|
||||
src/
|
||||
app/
|
||||
focus_seed_app.dart
|
||||
app_theme.dart
|
||||
core/
|
||||
date_keys.dart
|
||||
iterable_extensions.dart
|
||||
json_utils.dart
|
||||
data/
|
||||
focus_seed_store.dart
|
||||
models/
|
||||
app_state.dart
|
||||
focus_session.dart
|
||||
habit.dart
|
||||
mood.dart
|
||||
reflection_entry.dart
|
||||
services/
|
||||
analytics_provider.dart
|
||||
monetization_provider.dart
|
||||
features/
|
||||
shell/
|
||||
focus_seed_home.dart
|
||||
home/
|
||||
home_screen.dart
|
||||
timer/
|
||||
timer_screen.dart
|
||||
habits/
|
||||
habits_screen.dart
|
||||
reflection/
|
||||
reflection_screen.dart
|
||||
settings/
|
||||
settings_screen.dart
|
||||
widgets/
|
||||
app_scaffold.dart
|
||||
dialogs.dart
|
||||
empty_state.dart
|
||||
habit_row.dart
|
||||
inline_editable_text.dart
|
||||
loading_view.dart
|
||||
metric_tile.dart
|
||||
section_label.dart
|
||||
seed_card.dart
|
||||
settings_tile.dart
|
||||
weekly_progress.dart
|
||||
app/ app shell, theme, ambience, localization setup
|
||||
application/ product controllers and timer lifecycle
|
||||
core/ date keys, focus durations, ambience, utilities
|
||||
data/ local persistence boundary
|
||||
data/local/ Drift SQLite database
|
||||
models/ immutable product models and derived state
|
||||
services/ no-op analytics and monetization interfaces
|
||||
features/ Today, Focus, Records, Me, habits, onboarding, reflection, shell
|
||||
widgets/ reusable panels, dialogs, headers, loading, illustrations
|
||||
```
|
||||
|
||||
## Responsibility Boundaries
|
||||
|
||||
- `main.dart`: process entry only.
|
||||
- `src/app`: app shell and theme.
|
||||
- `src/models`: serializable domain objects.
|
||||
- `src/data`: persistence boundary using `shared_preferences`.
|
||||
- `src/services`: no-op interfaces for analytics and monetization.
|
||||
- `src/features/shell`: state orchestration and feature routing.
|
||||
- `src/app`: app-level theme, locale policy, ambience updates, and shell bootstrapping.
|
||||
- `src/application`: user commands, save rollback, local analytics events, and timer lifecycle.
|
||||
- `src/core`: framework-light utilities and product constants.
|
||||
- `src/data`: persistence boundary used by controllers.
|
||||
- `src/data/local`: Drift tables, SQLite connection, and product-data clearing.
|
||||
- `src/models`: product snapshots, sessions, habits, reflections, mood, records, and progress models.
|
||||
- `src/services`: SDK-light interfaces with no-op implementations.
|
||||
- `src/features/shell`: top-level navigation and controller wiring.
|
||||
- `src/features/*`: screen-level UI and user flows.
|
||||
- `src/widgets`: reusable UI components and dialogs.
|
||||
- `src/core`: framework-light utilities.
|
||||
- `src/widgets`: reusable UI primitives.
|
||||
|
||||
Widgets render state and forward intent. Controllers own commands. `FocusSeedStore` is the durable persistence boundary. Feature UI does not write to SQLite directly.
|
||||
|
||||
## Current Product Structure
|
||||
|
||||
Primary destinations:
|
||||
|
||||
- Today: daily focus, timer entry, habit summary, reflection prompt, and loop progress.
|
||||
- Focus: immersive timer and active-session context.
|
||||
- Records: completed focus sessions, reflections, and lightweight history.
|
||||
- Me: preferences, privacy, local data controls, and about/legal surfaces.
|
||||
|
||||
Habits are a Today support flow, not a primary tab. Growth/Journey was removed from the V1 primary surface; review belongs in Records until a future feature has enough depth to justify a dedicated destination.
|
||||
|
||||
## Persistence
|
||||
|
||||
Focus Seed is offline-first and stores product data locally with Drift/SQLite:
|
||||
|
||||
- `daily_intentions`
|
||||
- `focus_sessions`
|
||||
- `habits`
|
||||
- `habit_logs`
|
||||
- `reflections`
|
||||
- `app_settings`
|
||||
|
||||
The current user-facing app exposes no manual data transfer tools. Existing legacy local preference data is migrated once into SQLite when present, then removed. Small app preferences such as appearance and development language selection remain local preferences.
|
||||
|
||||
## Current Design Decisions
|
||||
|
||||
- No real ad SDK in V1.
|
||||
- No real billing SDK in V1.
|
||||
- No analytics SDK in V1.
|
||||
- No network dependency.
|
||||
- JSON import/export uses copy/paste to avoid file permissions.
|
||||
- Shell owns state until a future state-management package is justified.
|
||||
- No account, server, or network dependency.
|
||||
- Default focus duration is a user-facing local setting.
|
||||
- Clear today resets only today's loop.
|
||||
- Delete all data clears local product records while preserving app preference behavior where appropriate.
|
||||
|
||||
## Upgrade Path
|
||||
|
||||
Next modularization steps when needed:
|
||||
|
||||
- Move `src/app`, `src/core`, `src/models`, `src/services`, and `src/widgets` into shared packages.
|
||||
- Replace shell state with Riverpod once multiple apps/features share the same state patterns.
|
||||
- Add real `AnalyticsProvider`, `MonetizationProvider`, and remote config implementations only with privacy/Data safety updates.
|
||||
- Add golden tests for key screens before scaling the app factory.
|
||||
- Keep Drift as the local source of truth while the product remains offline-first.
|
||||
- Add DAO-style query methods before history grows large enough to make whole-snapshot writes inefficient.
|
||||
- Add Riverpod or a repository package only when multiple apps or features need shared state patterns.
|
||||
- Add real analytics, ads, billing, remote config, or sync only with privacy, Data safety, policy, and tests in the same release.
|
||||
- Add golden or screenshot tests before scaling launch screenshots or visual variants.
|
||||
|
||||
@@ -31,11 +31,11 @@ Official references:
|
||||
|
||||
2. Publish an internal testing release.
|
||||
- Upload `build/app/outputs/bundle/release/app-release.aab`.
|
||||
- Use release name `1.0.0 internal`.
|
||||
- Use release name `1.0.2 internal`.
|
||||
- Release notes:
|
||||
```text
|
||||
First internal test release.
|
||||
Includes focus timer, habit tracking, daily reflection, local progress stats, JSON export/import, and local privacy controls.
|
||||
Internal test release for Focus Seed.
|
||||
Includes focus timer, habit tracking, daily reflection, local progress stats, default focus duration, and local privacy controls.
|
||||
```
|
||||
- Add yourself and one or two trusted accounts if available.
|
||||
- Use this to confirm Play delivery and installation.
|
||||
@@ -45,11 +45,11 @@ Official references:
|
||||
- Create track name: `Focus Seed V1 closed test`.
|
||||
- Countries/regions: use English-first regions from `region-localization-strategy.md`.
|
||||
- Upload the same AAB.
|
||||
- Release name: `1.0.0 closed test`.
|
||||
- Use release name `1.0.2 closed test`.
|
||||
- Release notes:
|
||||
```text
|
||||
First closed test release for Focus Seed.
|
||||
Please test the Today, Timer, Habits, Reflect, Settings, Export JSON, Import JSON, and Clear local data flows.
|
||||
Closed test release for Focus Seed.
|
||||
Please test Today, Focus, Records, Me, habit management from Today, default focus duration, clear today's loop, delete local records, and privacy information.
|
||||
```
|
||||
|
||||
4. Add testers.
|
||||
@@ -58,11 +58,11 @@ Official references:
|
||||
- Minimum passing target: 12 opted-in testers.
|
||||
- Tester emails must be Google accounts that can access Google Play.
|
||||
- Adding emails is not enough. Each tester must open the opt-in link and join the test.
|
||||
- If uploading CSV, use one email address per line with no comma and no header row. Use `tester-email-list-no-header.csv` as the safer template.
|
||||
- If uploading CSV, use one email address per line with no comma and no header row.
|
||||
|
||||
5. Send tester instructions.
|
||||
- Send the opt-in link from Play Console.
|
||||
- Ask each tester to confirm after they see the "You're a tester" or equivalent joined state.
|
||||
- Ask each tester to confirm after they see the joined tester state.
|
||||
- Ask each tester to install and open the app at least once.
|
||||
|
||||
6. Track 14 days.
|
||||
@@ -74,14 +74,14 @@ Official references:
|
||||
- Apply from the Play Console dashboard when eligible.
|
||||
- Answer questions using the notes in this file.
|
||||
|
||||
## 12 Tester Strategy For A Solo Developer
|
||||
## Tester Strategy For A Solo Developer
|
||||
|
||||
Best compliant sources:
|
||||
|
||||
- Friends and family with Android phones and Google accounts.
|
||||
- Former classmates, coworkers, or community contacts.
|
||||
- Small indie developer groups where members exchange real testing help.
|
||||
- Productivity/focus communities where you honestly ask for feedback.
|
||||
- Productivity or focus communities where you honestly ask for feedback.
|
||||
- Existing users from your personal network.
|
||||
|
||||
Recommended count:
|
||||
@@ -108,35 +108,7 @@ Tester success checklist:
|
||||
- Tester tries at least one real flow.
|
||||
- Tester sends short feedback privately.
|
||||
|
||||
## Tester Invitation Message - Chinese
|
||||
|
||||
```text
|
||||
我在 Google Play 上做一个新的 Android 应用封闭测试,名字是 Focus Seed。
|
||||
它是一个轻量的专注计时、习惯追踪和每日反思工具,不需要账号、不收费、没有广告。
|
||||
|
||||
能麻烦你帮我测试一下吗?
|
||||
|
||||
步骤:
|
||||
1. 用你的 Android 手机打开这个测试链接:
|
||||
[粘贴 Play Console opt-in 链接]
|
||||
2. 确认加入测试。
|
||||
3. 从 Google Play 安装 Focus Seed。
|
||||
4. 打开 App,试一下:
|
||||
- Today 页面设置一个今日重点
|
||||
- Timer 页面启动或完成一次专注
|
||||
- Habits 页面勾选一个习惯
|
||||
- Reflect 页面写一句反思
|
||||
- Settings 页面看一下隐私/导出入口
|
||||
5. 测完后告诉我:
|
||||
- 是否能正常安装和打开
|
||||
- 哪个页面最不清楚
|
||||
- 有没有崩溃、卡顿、文字看不清或按钮点不了
|
||||
|
||||
这个测试需要保持加入状态 14 天。你不需要每天使用,但请不要退出测试。
|
||||
谢谢!
|
||||
```
|
||||
|
||||
## Tester Invitation Message - English
|
||||
## Tester Invitation Message
|
||||
|
||||
```text
|
||||
I am running a closed Google Play test for a new Android app called Focus Seed.
|
||||
@@ -150,11 +122,13 @@ Steps:
|
||||
2. Join the test.
|
||||
3. Install Focus Seed from Google Play.
|
||||
4. Try these flows:
|
||||
- Set a daily anchor on Today
|
||||
- Set today's focus on Today
|
||||
- Start or complete a focus timer
|
||||
- Check off a habit
|
||||
- Save a short reflection
|
||||
- Open Settings and review privacy/export controls
|
||||
- Open Records and view progress/history
|
||||
- Open Me and change the default focus duration
|
||||
- Review privacy information and local data controls
|
||||
5. Send me quick feedback:
|
||||
- Did install/open work?
|
||||
- Was any screen confusing?
|
||||
@@ -166,23 +140,20 @@ Thank you!
|
||||
|
||||
## Quick Feedback Questions
|
||||
|
||||
Send these as a message or simple form:
|
||||
|
||||
```text
|
||||
1. Your phone model:
|
||||
2. Android version, if known:
|
||||
3. Did the app install from Google Play? Yes / No
|
||||
4. Did the app open successfully? Yes / No
|
||||
5. Which screen did you try? Today / Timer / Habits / Reflect / Settings
|
||||
6. Any crash, freeze, lag, or layout issue?
|
||||
7. What felt confusing?
|
||||
8. One thing you would improve:
|
||||
5. Which screen did you try? Today / Focus / Records / Me / habit manager
|
||||
6. Did default focus duration work as expected?
|
||||
7. Any crash, freeze, lag, or layout issue?
|
||||
8. What felt confusing?
|
||||
9. One thing you would improve:
|
||||
```
|
||||
|
||||
## Tester Tracking Table
|
||||
|
||||
Use a spreadsheet or notes app:
|
||||
|
||||
```text
|
||||
Tester name | Google account email | Invited | Opted in | Installed | Opened app | Feedback received | Still opted in day 14
|
||||
```
|
||||
@@ -199,8 +170,8 @@ Target:
|
||||
Use these points when Play Console asks about the closed test:
|
||||
|
||||
- App purpose: Focus Seed is a private focus timer, habit tracker, and daily reflection app.
|
||||
- Test scope: Today anchor, timer, habits, reflection, settings, export/import, and local data deletion.
|
||||
- Test scope: Today focus, timer, habits, reflection, Records/history, Me/settings, default focus duration, clear today's loop, delete local records, and privacy information.
|
||||
- Tester recruitment: invited trusted Android users through direct email/message using the Play Console opt-in link.
|
||||
- Feedback collected: installation success, first-run clarity, timer usability, habit check-off, reflection save flow, settings/privacy clarity, layout or crash reports.
|
||||
- Actions taken: fixed native Android launch package path, removed unfinished ads/subscription placeholders from settings, verified release build on Android 14.
|
||||
- Actions taken: verified local-only storage, removed obsolete data-transfer wording, added default focus duration, and checked release-sensitive privacy wording.
|
||||
- Readiness: no account, no server, no ads, no billing, no analytics SDK, no sensitive permissions, privacy policy and Data safety match the shipped app.
|
||||
|
||||
@@ -8,8 +8,8 @@ This guidance assumes the submitted build is the current Focus Seed V1:
|
||||
- No analytics SDK.
|
||||
- No billing SDK.
|
||||
- No network permission.
|
||||
- Data stored locally with `shared_preferences`.
|
||||
- Export/import is user-controlled copy/paste JSON.
|
||||
- Data stored locally with Drift/SQLite plus small local preferences.
|
||||
- User can clear today's loop or delete local app records from settings.
|
||||
|
||||
## Suggested Data Safety Answers
|
||||
|
||||
|
||||
@@ -10,9 +10,9 @@ Private focus timer, habit tracker, and one-minute daily reflection.
|
||||
|
||||
## Full Description
|
||||
|
||||
Focus Seed helps you turn each day into a small, trackable win. Choose today’s anchor task, start a focused timer, mark habits as done, and write a short reflection when you finish.
|
||||
Focus Seed helps you turn each day into a small, trackable win. Choose today's anchor task, start a focused timer, mark habits as done, and write a short reflection when you finish.
|
||||
|
||||
Everything works locally on your device. No account is required, and your focus sessions, habits, and reflections stay on your phone unless you choose to export them.
|
||||
Everything works locally on your device. No account is required, and your focus sessions, habits, reflections, and preferences stay on your phone.
|
||||
|
||||
Features:
|
||||
|
||||
@@ -22,7 +22,7 @@ Features:
|
||||
- One-minute reflection log
|
||||
- Mood tags
|
||||
- Local 7-day progress view
|
||||
- JSON export and import
|
||||
- Default focus duration preference
|
||||
- Clear local-data privacy controls
|
||||
|
||||
Focus Seed is designed for people who want a lightweight productivity tool without accounts, feeds, or complicated setup.
|
||||
@@ -47,11 +47,11 @@ focus timer, habit tracker, reflection, productivity, offline, private, daily ro
|
||||
|
||||
## Release Notes
|
||||
|
||||
First release:
|
||||
Current release:
|
||||
|
||||
- Focus timer
|
||||
- Habit tracker
|
||||
- Daily reflection
|
||||
- Local progress stats
|
||||
- Local JSON export/import
|
||||
- Default focus duration setting
|
||||
- In-app privacy controls
|
||||
|
||||
@@ -71,17 +71,15 @@
|
||||
<li>Habit names and completion dates</li>
|
||||
<li>Focus session completion time and duration</li>
|
||||
<li>Reflection notes and mood tags</li>
|
||||
<li>App preferences such as appearance and default focus duration</li>
|
||||
<li>Basic app state needed for the app to work</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data Sharing</h2>
|
||||
<p>Focus Seed V1 does not share your app data with the developer or third parties.</p>
|
||||
|
||||
<h2>Export And Import</h2>
|
||||
<p>You can export your local data as JSON from the app settings. You control where that exported text is copied or stored. You can also paste a previous JSON export into the app to import it.</p>
|
||||
|
||||
<h2>Data Deletion</h2>
|
||||
<p>You can clear local app data from Settings. You can also remove data by uninstalling the app or clearing app storage in Android settings.</p>
|
||||
<p>You can clear today's loop or delete local app records from Settings. You can also remove data by uninstalling the app or clearing app storage in Android settings.</p>
|
||||
|
||||
<h2>Ads, Analytics, And Purchases</h2>
|
||||
<p>Focus Seed V1 does not include advertising SDKs, analytics SDKs, or in-app purchase SDKs.</p>
|
||||
|
||||
@@ -16,19 +16,16 @@ The app stores the following data locally on your device:
|
||||
- Habit names and completion dates
|
||||
- Focus session completion time and duration
|
||||
- Reflection notes and mood tags
|
||||
- App preferences such as appearance and default focus duration
|
||||
- Basic app state needed for the app to work
|
||||
|
||||
## Data Sharing
|
||||
|
||||
Focus Seed V1 does not share your app data with the developer or third parties.
|
||||
|
||||
## Export And Import
|
||||
|
||||
You can export your local data as JSON from the app settings. You control where that exported text is copied or stored. You can also paste a previous JSON export into the app to import it.
|
||||
|
||||
## Data Deletion
|
||||
|
||||
You can clear local app data from Settings. You can also remove data by uninstalling the app or clearing app storage in Android settings.
|
||||
You can clear today's loop or delete local app records from Settings. You can also remove data by uninstalling the app or clearing app storage in Android settings.
|
||||
|
||||
## Ads, Analytics, And Purchases
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Focus Seed Product Brief
|
||||
|
||||
Status: V1 release candidate
|
||||
Status: historical release reference. Superseded for active product decisions by `docs/product-current-baseline.md`.
|
||||
|
||||
## Positioning
|
||||
|
||||
@@ -13,10 +13,11 @@ Focus Seed is a private, offline-first focus timer, habit tracker, and one-minut
|
||||
- Habit creation, rename, delete, and daily check-off.
|
||||
- One-minute reflection with mood tag.
|
||||
- 7-day progress pulse.
|
||||
- Local JSON export and import by copy/paste.
|
||||
- In-app privacy explanation.
|
||||
- Local data controls: clear today's loop or delete all local records.
|
||||
- User-facing settings for appearance and default focus duration.
|
||||
- No-op monetization and analytics interfaces.
|
||||
- English-only UI and store listing for the first launch wave.
|
||||
- Release defaults to English UI and English store listing for the first launch wave. Chinese remains development/testing support only.
|
||||
|
||||
## V1 Exclusions
|
||||
|
||||
@@ -50,7 +51,7 @@ Defer EU/EEA, United Kingdom, Switzerland, South Korea, Japan, Brazil/LATAM, and
|
||||
## Future Revenue Path
|
||||
|
||||
- V2/V3: optional low-interruption ads after retention validation.
|
||||
- V4/V5: one-time unlock or subscription for advanced statistics, themes, export templates, widgets, and backup/sync.
|
||||
- V4/V5: one-time unlock or subscription for advanced statistics, premium themes, widgets, and backup/sync.
|
||||
- Free core loop remains usable.
|
||||
|
||||
## App IDs
|
||||
@@ -59,6 +60,6 @@ Defer EU/EEA, United Kingdom, Switzerland, South Korea, Japan, Brazil/LATAM, and
|
||||
- Developer / brand: leavysong
|
||||
- Support email: leavysong@gmail.com
|
||||
- Package/application ID: `com.dailyanchor.focusseed`
|
||||
- Version: `1.0.0+1`
|
||||
- Version: `1.0.2+3`
|
||||
- Android min SDK: 26
|
||||
- Android target SDK: 35
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Focus Seed 1.0.1 Experience Brief
|
||||
|
||||
Status: approved product and design principles, visual route not yet selected.
|
||||
Status: historical experience reference. Superseded for active product direction by `../../docs/product-current-baseline.md`.
|
||||
|
||||
Previous status: approved product and design principles, visual route not yet selected.
|
||||
|
||||
## Product Intent
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Recommended phone screenshots:
|
||||
2. Focus timer with progress ring.
|
||||
3. Habits screen with completed and pending habits.
|
||||
4. Reflection screen with mood tags and text area.
|
||||
5. Settings screen showing local data export/import and privacy.
|
||||
5. Settings screen showing appearance, default focus duration, local data controls, and privacy.
|
||||
|
||||
Suggested captions:
|
||||
|
||||
@@ -26,7 +26,7 @@ Suggested captions:
|
||||
- Start a calm focus session.
|
||||
- Track tiny habits without an account.
|
||||
- Finish with a one-minute reflection.
|
||||
- Keep your data local and export when needed.
|
||||
- Keep your records local and under your control.
|
||||
|
||||
Feature graphic direction:
|
||||
|
||||
|
||||
+87
-110
@@ -4,33 +4,38 @@ import '../core/ambience.dart';
|
||||
|
||||
class AppTheme {
|
||||
static const ink = Color(0xFF17211C);
|
||||
static const leaf = Color(0xFF4D8B63);
|
||||
static const mint = Color(0xFFA9E8B5);
|
||||
static const ultramarine = Color(0xFF4E63D8);
|
||||
static const coral = Color(0xFFEA725E);
|
||||
static const violet = Color(0xFF7856D8);
|
||||
static const porcelain = Color(0xFFF7F8F5);
|
||||
static const forest = Color(0xFF08785B);
|
||||
static const teal = Color(0xFF0A8179);
|
||||
static const moss = Color(0xFF6C8F6B);
|
||||
static const mint = Color(0xFFA8E6C1);
|
||||
static const ultramarine = Color(0xFF2E6FDB);
|
||||
static const sky = Color(0xFF42A5F5);
|
||||
static const amber = Color(0xFFE3A321);
|
||||
static const coral = Color(0xFFD9654F);
|
||||
static const violet = Color(0xFF7257C8);
|
||||
static const paper = Color(0xFFFAFBFF);
|
||||
static const porcelain = Color(0xFFF4F7FC);
|
||||
|
||||
static ThemeData light(DayPhase phase) {
|
||||
final surface = switch (phase) {
|
||||
DayPhase.dawn => const Color(0xFFF3F8F7),
|
||||
DayPhase.dawn => const Color(0xFFF3F8FC),
|
||||
DayPhase.day => porcelain,
|
||||
DayPhase.dusk => const Color(0xFFFBF5F2),
|
||||
DayPhase.night => const Color(0xFFF3F5F3),
|
||||
DayPhase.dusk => const Color(0xFFFBF6F4),
|
||||
DayPhase.night => const Color(0xFFF2F5FA),
|
||||
};
|
||||
final scheme = ColorScheme.fromSeed(seedColor: leaf).copyWith(
|
||||
primary: leaf,
|
||||
final scheme = ColorScheme.fromSeed(seedColor: ultramarine).copyWith(
|
||||
primary: ultramarine,
|
||||
onPrimary: Colors.white,
|
||||
secondary: ultramarine,
|
||||
tertiary: coral,
|
||||
tertiary: amber,
|
||||
surface: surface,
|
||||
onSurface: ink,
|
||||
surfaceContainerLowest: Colors.white.withValues(alpha: 0.82),
|
||||
surfaceContainerLow: Colors.white.withValues(alpha: 0.72),
|
||||
surfaceContainer: const Color(0xFFF0F3EF),
|
||||
surfaceContainerHigh: const Color(0xFFE9EEE9),
|
||||
outline: const Color(0xFFB9C2BA),
|
||||
outlineVariant: const Color(0xFFDDE3DD),
|
||||
surfaceContainerLowest: const Color(0xFFFFFEFA),
|
||||
surfaceContainerLow: const Color(0xFFEFF3FA),
|
||||
surfaceContainer: const Color(0xFFE4EAF4),
|
||||
surfaceContainerHigh: const Color(0xFFD6DEEB),
|
||||
outline: const Color(0xFF9EA9B8),
|
||||
outlineVariant: const Color(0xFFD5DEEA),
|
||||
);
|
||||
return _base(scheme, phase);
|
||||
}
|
||||
@@ -42,50 +47,32 @@ class AppTheme {
|
||||
DayPhase.dusk => const Color(0xFF1A181A),
|
||||
DayPhase.night => const Color(0xFF101614),
|
||||
};
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: mint,
|
||||
brightness: Brightness.dark,
|
||||
).copyWith(
|
||||
primary: mint,
|
||||
onPrimary: ink,
|
||||
secondary: const Color(0xFFB9C2FF),
|
||||
tertiary: const Color(0xFFFFB4A8),
|
||||
final scheme = ColorScheme.fromSeed(seedColor: mint, brightness: Brightness.dark).copyWith(
|
||||
primary: const Color(0xFF8FBAFF),
|
||||
onPrimary: const Color(0xFF071226),
|
||||
secondary: const Color(0xFF8FBAFF),
|
||||
tertiary: const Color(0xFFFFD782),
|
||||
surface: surface,
|
||||
onSurface: const Color(0xFFF1F5F1),
|
||||
surfaceContainerLowest: const Color(0xFF0D1210),
|
||||
surfaceContainerLow: const Color(0xFF171E1B),
|
||||
surfaceContainer: const Color(0xFF1C2420),
|
||||
surfaceContainerHigh: const Color(0xFF243029),
|
||||
outline: const Color(0xFF7E8D83),
|
||||
outlineVariant: const Color(0xFF344039),
|
||||
surfaceContainerLow: const Color(0xFF151C24),
|
||||
surfaceContainer: const Color(0xFF1B2430),
|
||||
surfaceContainerHigh: const Color(0xFF253142),
|
||||
outline: const Color(0xFF7F8EA4),
|
||||
outlineVariant: const Color(0xFF344153),
|
||||
);
|
||||
return _base(scheme, phase);
|
||||
}
|
||||
|
||||
static ThemeData _base(ColorScheme scheme, DayPhase phase) {
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: scheme.surface,
|
||||
);
|
||||
final base = ThemeData(useMaterial3: true, colorScheme: scheme, scaffoldBackgroundColor: scheme.surface);
|
||||
final textTheme = base.textTheme.copyWith(
|
||||
headlineSmall: base.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.15,
|
||||
),
|
||||
titleLarge: base.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.2,
|
||||
),
|
||||
titleMedium: base.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.25,
|
||||
),
|
||||
headlineSmall: base.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700, height: 1.15),
|
||||
titleLarge: base.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700, height: 1.2),
|
||||
titleMedium: base.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, height: 1.25),
|
||||
bodyLarge: base.textTheme.bodyLarge?.copyWith(height: 1.4),
|
||||
bodyMedium: base.textTheme.bodyMedium?.copyWith(height: 1.4),
|
||||
labelLarge: base.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
labelLarge: base.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
);
|
||||
return base.copyWith(
|
||||
textTheme: textTheme,
|
||||
@@ -94,34 +81,22 @@ class AppTheme {
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
color: scheme.surfaceContainerLowest,
|
||||
shadowColor: ink.withValues(alpha: 0.12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
shadowColor: ink.withValues(alpha: 0.10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
height: 68,
|
||||
height: 72,
|
||||
elevation: 0,
|
||||
backgroundColor: scheme.surfaceContainerLowest,
|
||||
indicatorColor: scheme.primary.withValues(alpha: 0.14),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(states) => TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
states.contains(WidgetState.selected)
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
indicatorColor: scheme.primary.withValues(alpha: 0.13),
|
||||
iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData(size: 23, color: states.contains(WidgetState.selected) ? scheme.primary : scheme.onSurfaceVariant)),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) => TextStyle(fontSize: 12, fontWeight: states.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500)),
|
||||
),
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
@@ -129,47 +104,24 @@ class AppTheme {
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(48, 48),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: scheme.surfaceContainerLow,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 13,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
chipTheme: base.chipTheme.copyWith(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
side: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: scheme.outlineVariant,
|
||||
space: 1,
|
||||
thickness: 1,
|
||||
),
|
||||
dialogTheme: DialogTheme(
|
||||
backgroundColor: scheme.surfaceContainerLowest,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.22))),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.22))),
|
||||
),
|
||||
chipTheme: base.chipTheme.copyWith(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.38))),
|
||||
dividerTheme: DividerThemeData(color: scheme.outlineVariant.withValues(alpha: 0.64), space: 1, thickness: 1),
|
||||
dialogTheme: DialogTheme(backgroundColor: scheme.surfaceContainerLowest, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
backgroundColor: scheme.surface,
|
||||
modalBackgroundColor: scheme.surface,
|
||||
showDragHandle: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
),
|
||||
extensions: [FocusSeedVisuals.forPhase(phase, scheme.brightness)],
|
||||
);
|
||||
@@ -180,14 +132,26 @@ class AppTheme {
|
||||
class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
|
||||
const FocusSeedVisuals({
|
||||
required this.ambientTint,
|
||||
required this.brand,
|
||||
required this.action,
|
||||
required this.focus,
|
||||
required this.habit,
|
||||
required this.reflection,
|
||||
required this.success,
|
||||
required this.privacy,
|
||||
required this.warning,
|
||||
required this.shadow,
|
||||
});
|
||||
|
||||
final Color ambientTint;
|
||||
final Color brand;
|
||||
final Color action;
|
||||
final Color focus;
|
||||
final Color habit;
|
||||
final Color reflection;
|
||||
final Color success;
|
||||
final Color privacy;
|
||||
final Color warning;
|
||||
final Color shadow;
|
||||
|
||||
static FocusSeedVisuals of(BuildContext context) {
|
||||
@@ -203,24 +167,31 @@ class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
|
||||
DayPhase.night => const Color(0xFF9A89D8),
|
||||
};
|
||||
return FocusSeedVisuals(
|
||||
ambientTint: ambientTint.withValues(alpha: isDark ? 0.18 : 0.22),
|
||||
reflection: isDark ? const Color(0xFFBDAAFF) : AppTheme.violet,
|
||||
success: isDark ? AppTheme.mint : AppTheme.leaf,
|
||||
ambientTint: ambientTint.withValues(alpha: isDark ? 0.14 : 0.18),
|
||||
brand: isDark ? const Color(0xFFF1F5F1) : AppTheme.ink,
|
||||
action: isDark ? const Color(0xFFFFB09D) : AppTheme.coral,
|
||||
focus: isDark ? const Color(0xFF8FBAFF) : AppTheme.ultramarine,
|
||||
habit: isDark ? const Color(0xFFFFD782) : AppTheme.amber,
|
||||
reflection: isDark ? const Color(0xFFC7B8FF) : AppTheme.violet,
|
||||
success: isDark ? AppTheme.mint : AppTheme.forest,
|
||||
privacy: isDark ? const Color(0xFF8DDFC1) : AppTheme.forest,
|
||||
warning: isDark ? const Color(0xFFFFB4A8) : AppTheme.coral,
|
||||
shadow: Colors.black.withValues(alpha: isDark ? 0.32 : 0.10),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
FocusSeedVisuals copyWith({
|
||||
Color? ambientTint,
|
||||
Color? reflection,
|
||||
Color? success,
|
||||
Color? shadow,
|
||||
}) {
|
||||
FocusSeedVisuals copyWith({Color? ambientTint, Color? brand, Color? action, Color? focus, Color? habit, Color? reflection, Color? success, Color? privacy, Color? warning, Color? shadow}) {
|
||||
return FocusSeedVisuals(
|
||||
ambientTint: ambientTint ?? this.ambientTint,
|
||||
brand: brand ?? this.brand,
|
||||
action: action ?? this.action,
|
||||
focus: focus ?? this.focus,
|
||||
habit: habit ?? this.habit,
|
||||
reflection: reflection ?? this.reflection,
|
||||
success: success ?? this.success,
|
||||
privacy: privacy ?? this.privacy,
|
||||
warning: warning ?? this.warning,
|
||||
shadow: shadow ?? this.shadow,
|
||||
);
|
||||
}
|
||||
@@ -232,8 +203,14 @@ class FocusSeedVisuals extends ThemeExtension<FocusSeedVisuals> {
|
||||
}
|
||||
return FocusSeedVisuals(
|
||||
ambientTint: Color.lerp(ambientTint, other.ambientTint, t)!,
|
||||
brand: Color.lerp(brand, other.brand, t)!,
|
||||
action: Color.lerp(action, other.action, t)!,
|
||||
focus: Color.lerp(focus, other.focus, t)!,
|
||||
habit: Color.lerp(habit, other.habit, t)!,
|
||||
reflection: Color.lerp(reflection, other.reflection, t)!,
|
||||
success: Color.lerp(success, other.success, t)!,
|
||||
privacy: Color.lerp(privacy, other.privacy, t)!,
|
||||
warning: Color.lerp(warning, other.warning, t)!,
|
||||
shadow: Color.lerp(shadow, other.shadow, t)!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import '../core/ambience.dart';
|
||||
import '../data/app_preferences_store.dart';
|
||||
import '../data/focus_seed_store.dart';
|
||||
import '../features/onboarding/welcome_screen.dart';
|
||||
import '../features/shell/focus_seed_home.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../models/appearance_preference.dart';
|
||||
import '../models/language_preference.dart';
|
||||
import '../widgets/loading_view.dart';
|
||||
import 'app_theme.dart';
|
||||
|
||||
@visibleForTesting
|
||||
@@ -19,10 +21,16 @@ Locale focusSeedDefaultLocale({bool isRelease = kReleaseMode}) {
|
||||
}
|
||||
|
||||
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 FocusSeedStore? focusSeedStore;
|
||||
final bool showWelcomeOnFirstRun;
|
||||
|
||||
@override
|
||||
State<FocusSeedApp> createState() => _FocusSeedAppState();
|
||||
@@ -33,6 +41,10 @@ class _FocusSeedAppState extends State<FocusSeedApp>
|
||||
final _preferencesStore = AppPreferencesStore();
|
||||
AppearancePreference _appearance = AppearancePreference.system;
|
||||
LanguagePreference _language = LanguagePreference.defaultLocale;
|
||||
bool _onboardingComplete = true;
|
||||
bool _landingSetupComplete = true;
|
||||
bool _showLandingSetup = false;
|
||||
bool _preferencesLoaded = false;
|
||||
DayPhase _dayPhase = dayPhaseFor(DateTime.now());
|
||||
Timer? _phaseTimer;
|
||||
|
||||
@@ -52,14 +64,25 @@ class _FocusSeedAppState extends State<FocusSeedApp>
|
||||
}
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
final appearance = await _preferencesStore.loadAppearance();
|
||||
final language = await _preferencesStore.loadLanguage();
|
||||
final (appearance, language, firstRunProgress) =
|
||||
await (
|
||||
_preferencesStore.loadAppearance(),
|
||||
_preferencesStore.loadLanguage(),
|
||||
_preferencesStore.loadFirstRunProgress(),
|
||||
).wait;
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_appearance = appearance;
|
||||
_language = language;
|
||||
_onboardingComplete = firstRunProgress.onboardingComplete;
|
||||
_landingSetupComplete = firstRunProgress.landingSetupComplete;
|
||||
_showLandingSetup =
|
||||
widget.showWelcomeOnFirstRun &&
|
||||
firstRunProgress.onboardingComplete &&
|
||||
!firstRunProgress.landingSetupComplete;
|
||||
_preferencesLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,6 +121,22 @@ class _FocusSeedAppState extends State<FocusSeedApp>
|
||||
return widget.locale ?? _language.locale ?? focusSeedDefaultLocale();
|
||||
}
|
||||
|
||||
Future<void> _completeOnboarding() async {
|
||||
setState(() {
|
||||
_onboardingComplete = true;
|
||||
_showLandingSetup = true;
|
||||
});
|
||||
await _preferencesStore.saveOnboardingComplete();
|
||||
}
|
||||
|
||||
Future<void> _completeLandingSetup() async {
|
||||
setState(() {
|
||||
_landingSetupComplete = true;
|
||||
_showLandingSetup = false;
|
||||
});
|
||||
await _preferencesStore.saveLandingSetupComplete();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
@@ -115,14 +154,28 @@ class _FocusSeedAppState extends State<FocusSeedApp>
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: FocusSeedHome(
|
||||
focusSeedStore: widget.focusSeedStore,
|
||||
appearance: _appearance,
|
||||
onAppearanceChanged: _setAppearance,
|
||||
language: _language,
|
||||
showLanguageTools: !kReleaseMode && widget.locale == null,
|
||||
onLanguageChanged: _setLanguage,
|
||||
),
|
||||
home:
|
||||
!_preferencesLoaded
|
||||
? const Scaffold(body: SafeArea(child: LoadingView()))
|
||||
: widget.showWelcomeOnFirstRun && !_onboardingComplete
|
||||
? WelcomeScreen(
|
||||
onStart: _completeOnboarding,
|
||||
onSkip: _completeOnboarding,
|
||||
)
|
||||
: FocusSeedHome(
|
||||
focusSeedStore: widget.focusSeedStore,
|
||||
appearance: _appearance,
|
||||
onAppearanceChanged: _setAppearance,
|
||||
language: _language,
|
||||
showLanguageTools: !kReleaseMode && widget.locale == null,
|
||||
onLanguageChanged: _setLanguage,
|
||||
showLandingSetupInitially:
|
||||
widget.showWelcomeOnFirstRun &&
|
||||
(!_landingSetupComplete || _showLandingSetup),
|
||||
onLandingSetupComplete: () {
|
||||
unawaited(_completeLandingSetup());
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../core/date_keys.dart';
|
||||
import '../core/focus_durations.dart';
|
||||
import '../data/focus_seed_store.dart';
|
||||
import '../models/app_state.dart';
|
||||
import '../models/focus_session.dart';
|
||||
@@ -55,12 +56,18 @@ class FocusSeedController extends ChangeNotifier {
|
||||
|
||||
Future<bool> setTodayAnchor(String value) async {
|
||||
final current = _state;
|
||||
final anchor = value.trim();
|
||||
if (current == null || anchor.isEmpty) {
|
||||
final title = value.trim();
|
||||
if (current == null || title.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
_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 {
|
||||
@@ -128,46 +135,86 @@ class FocusSeedController extends ChangeNotifier {
|
||||
return _commit(current.copyWith(habits: habits));
|
||||
}
|
||||
|
||||
Future<bool> completeFocusSession(int minutes) async {
|
||||
Future<bool> completeFocusSession(int minutes, {int? actualSeconds}) async {
|
||||
final current = _state;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final today = todayKey();
|
||||
final anchor = current.activeAnchor(today);
|
||||
final session = FocusSession(
|
||||
id: 'session-${now.microsecondsSinceEpoch}',
|
||||
minutes: minutes,
|
||||
actualSeconds: actualSeconds,
|
||||
completedAt: now,
|
||||
intention: anchor,
|
||||
actionTitleSnapshot: anchor,
|
||||
);
|
||||
_analytics.track('focus_completed', {
|
||||
'minutes': minutes,
|
||||
'actualSeconds': session.actualSeconds,
|
||||
});
|
||||
return _commit(
|
||||
current.copyWith(
|
||||
anchor: anchor,
|
||||
anchorDateKey: today,
|
||||
sessions: [...current.sessions, session],
|
||||
),
|
||||
);
|
||||
_analytics.track('focus_completed', {'minutes': minutes});
|
||||
return _commit(current.copyWith(sessions: [...current.sessions, session]));
|
||||
}
|
||||
|
||||
Future<bool> saveReflection(String value, Mood mood) async {
|
||||
return saveReflectionForDate(todayKey(), value, mood);
|
||||
}
|
||||
|
||||
Future<bool> saveReflectionForDate(
|
||||
String dateKey,
|
||||
String value,
|
||||
Mood mood,
|
||||
) async {
|
||||
final current = _state;
|
||||
final note = value.trim();
|
||||
if (current == null || note.isEmpty) {
|
||||
final trimmedNote = value.trim();
|
||||
final targetDateKey = dateKey.trim();
|
||||
if (current == null || targetDateKey.isEmpty || trimmedNote.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final today = todayKey();
|
||||
final now = DateTime.now();
|
||||
final entry = ReflectionEntry(
|
||||
id: 'reflection-${now.microsecondsSinceEpoch}',
|
||||
dateKey: today,
|
||||
note: note,
|
||||
dateKey: targetDateKey,
|
||||
note: trimmedNote,
|
||||
mood: mood,
|
||||
createdAt: now,
|
||||
);
|
||||
final reflections = [
|
||||
...current.reflections.where((item) => item.dateKey != today),
|
||||
...current.reflections.where((item) => item.dateKey != targetDateKey),
|
||||
entry,
|
||||
];
|
||||
_analytics.track('reflection_saved');
|
||||
return _commit(current.copyWith(reflections: reflections));
|
||||
}
|
||||
|
||||
Future<bool> setDefaultFocusMinutes(int minutes) async {
|
||||
final current = _state;
|
||||
if (current == null || !isFocusDurationPreset(minutes)) {
|
||||
return false;
|
||||
}
|
||||
if (current.defaultFocusMinutes == minutes) {
|
||||
return true;
|
||||
}
|
||||
_analytics.track('default_focus_minutes_changed', {'minutes': minutes});
|
||||
return _commit(current.copyWith(defaultFocusMinutes: minutes));
|
||||
}
|
||||
|
||||
Future<bool> clearData() async {
|
||||
return _commit(AppState.starter(defaultHabitTitles: _defaultHabitTitles));
|
||||
final current = _state;
|
||||
return _commit(
|
||||
AppState.starter(defaultHabitTitles: _defaultHabitTitles).copyWith(
|
||||
defaultFocusMinutes:
|
||||
current?.defaultFocusMinutes ?? defaultFocusDurationMinutes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> clearToday() async {
|
||||
@@ -190,12 +237,22 @@ class FocusSeedController extends ChangeNotifier {
|
||||
final reflections = current.reflections
|
||||
.where((reflection) => reflection.dateKey != today)
|
||||
.toList(growable: false);
|
||||
final actions = current.actions
|
||||
.where((action) => action.dateKey != today)
|
||||
.toList(growable: false);
|
||||
final progressNotes = current.progressNotes
|
||||
.where((note) => note.dateKey != today)
|
||||
.toList(growable: false);
|
||||
_analytics.track('today_cleared');
|
||||
return _commit(
|
||||
current.copyWith(
|
||||
anchor: current.anchorDateKey == today ? '' : current.anchor,
|
||||
selectedActionId:
|
||||
current.anchorDateKey == today ? '' : current.selectedActionId,
|
||||
habits: habits,
|
||||
actions: actions,
|
||||
sessions: sessions,
|
||||
progressNotes: progressNotes,
|
||||
reflections: reflections,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,21 +3,30 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../core/focus_durations.dart';
|
||||
import '../data/focus_timer_session_store.dart';
|
||||
|
||||
enum FocusTimerStatus { idle, running, paused, completed }
|
||||
|
||||
typedef FocusTimerCompleted =
|
||||
Future<bool> Function(int minutes, int actualSeconds);
|
||||
|
||||
class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
FocusTimerController({
|
||||
required Future<void> Function(int minutes) onCompleted,
|
||||
int initialMinutes = 25,
|
||||
required FocusTimerCompleted onCompleted,
|
||||
FocusTimerSessionStore? sessionStore,
|
||||
int initialMinutes = defaultFocusDurationMinutes,
|
||||
}) : _onCompleted = onCompleted,
|
||||
_sessionStore = sessionStore,
|
||||
_selectedMinutes = initialMinutes,
|
||||
_remainingSeconds = initialMinutes * 60 {
|
||||
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;
|
||||
DateTime? _endsAt;
|
||||
int _selectedMinutes;
|
||||
@@ -27,6 +36,8 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
|
||||
int get selectedMinutes => _selectedMinutes;
|
||||
int get remainingSeconds => _remainingSeconds;
|
||||
int get elapsedSeconds =>
|
||||
math.max(0, (_selectedMinutes * 60) - _remainingSeconds);
|
||||
FocusTimerStatus get status => _status;
|
||||
bool get isRunning => _status == FocusTimerStatus.running;
|
||||
bool get isPaused => _status == FocusTimerStatus.paused;
|
||||
@@ -38,6 +49,53 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
: (1 - (_remainingSeconds / totalSeconds)).clamp(0, 1);
|
||||
}
|
||||
|
||||
Future<bool> restoreSavedSession() async {
|
||||
final sessionStore = _sessionStore;
|
||||
if (sessionStore == null) {
|
||||
return false;
|
||||
}
|
||||
final snapshot = await sessionStore.load();
|
||||
if (snapshot == null) {
|
||||
return false;
|
||||
}
|
||||
if (!presets.contains(snapshot.selectedMinutes)) {
|
||||
await sessionStore.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
_selectedMinutes = snapshot.selectedMinutes;
|
||||
final totalSeconds = _selectedMinutes * 60;
|
||||
_remainingSeconds =
|
||||
snapshot.remainingSeconds.clamp(0, totalSeconds).toInt();
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
|
||||
switch (snapshot.status) {
|
||||
case FocusTimerSnapshotStatus.running:
|
||||
_status = FocusTimerStatus.running;
|
||||
_endsAt =
|
||||
snapshot.endsAt ??
|
||||
DateTime.now().add(Duration(seconds: _remainingSeconds));
|
||||
_refreshRemainingTime();
|
||||
if (_remainingSeconds <= 0) {
|
||||
unawaited(_complete());
|
||||
} else {
|
||||
_startTicker();
|
||||
}
|
||||
break;
|
||||
case FocusTimerSnapshotStatus.paused:
|
||||
_status = FocusTimerStatus.paused;
|
||||
break;
|
||||
case FocusTimerSnapshotStatus.completed:
|
||||
_remainingSeconds = 0;
|
||||
_status = FocusTimerStatus.completed;
|
||||
unawaited(_retryCompletedSnapshot(snapshot));
|
||||
break;
|
||||
}
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void selectMinutes(int minutes) {
|
||||
if (isRunning || !presets.contains(minutes)) {
|
||||
return;
|
||||
@@ -58,6 +116,7 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_status = FocusTimerStatus.running;
|
||||
_endsAt = DateTime.now().add(Duration(seconds: _remainingSeconds));
|
||||
_startTicker();
|
||||
unawaited(_persistActiveSession());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -69,6 +128,7 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_status = FocusTimerStatus.paused;
|
||||
unawaited(_persistActiveSession());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -77,6 +137,7 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_endsAt = null;
|
||||
_remainingSeconds = _selectedMinutes * 60;
|
||||
_status = FocusTimerStatus.idle;
|
||||
unawaited(_clearSavedSession());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -118,19 +179,100 @@ class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
if (_isCompleting || isCompleted) {
|
||||
return;
|
||||
}
|
||||
if (isRunning) {
|
||||
_refreshRemainingTime();
|
||||
}
|
||||
final totalSeconds = _selectedMinutes * 60;
|
||||
final actualSeconds =
|
||||
_remainingSeconds <= 0
|
||||
? totalSeconds
|
||||
: math.max(1, totalSeconds - _remainingSeconds);
|
||||
_isCompleting = true;
|
||||
_ticker?.cancel();
|
||||
_endsAt = null;
|
||||
_remainingSeconds = 0;
|
||||
_status = FocusTimerStatus.completed;
|
||||
unawaited(_persistCompletedSession(actualSeconds));
|
||||
notifyListeners();
|
||||
try {
|
||||
await _onCompleted(_selectedMinutes);
|
||||
final saved = await _onCompleted(_selectedMinutes, actualSeconds);
|
||||
if (saved) {
|
||||
await _clearSavedSession();
|
||||
}
|
||||
} finally {
|
||||
_isCompleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retryCompletedSnapshot(
|
||||
FocusTimerSessionSnapshot snapshot,
|
||||
) async {
|
||||
if (_isCompleting || snapshot.actualSeconds == null) {
|
||||
return;
|
||||
}
|
||||
_isCompleting = true;
|
||||
try {
|
||||
final saved = await _onCompleted(
|
||||
snapshot.selectedMinutes,
|
||||
snapshot.actualSeconds!,
|
||||
);
|
||||
if (saved) {
|
||||
await _clearSavedSession();
|
||||
}
|
||||
} finally {
|
||||
_isCompleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistActiveSession() async {
|
||||
final sessionStore = _sessionStore;
|
||||
if (sessionStore == null) {
|
||||
return;
|
||||
}
|
||||
if (isRunning) {
|
||||
await sessionStore.save(
|
||||
FocusTimerSessionSnapshot(
|
||||
status: FocusTimerSnapshotStatus.running,
|
||||
selectedMinutes: _selectedMinutes,
|
||||
remainingSeconds: _remainingSeconds,
|
||||
endsAt: _endsAt,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isPaused) {
|
||||
await sessionStore.save(
|
||||
FocusTimerSessionSnapshot(
|
||||
status: FocusTimerSnapshotStatus.paused,
|
||||
selectedMinutes: _selectedMinutes,
|
||||
remainingSeconds: _remainingSeconds,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistCompletedSession(int actualSeconds) async {
|
||||
final sessionStore = _sessionStore;
|
||||
if (sessionStore == null) {
|
||||
return;
|
||||
}
|
||||
await sessionStore.save(
|
||||
FocusTimerSessionSnapshot(
|
||||
status: FocusTimerSnapshotStatus.completed,
|
||||
selectedMinutes: _selectedMinutes,
|
||||
remainingSeconds: 0,
|
||||
actualSeconds: actualSeconds,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clearSavedSession() async {
|
||||
await _sessionStore?.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
@@ -22,7 +22,7 @@ List<String> lastSevenDateKeys() {
|
||||
int minutesForDate(List<FocusSession> sessions, String key) {
|
||||
return sessions
|
||||
.where((session) => dateKey(session.completedAt) == key)
|
||||
.fold<int>(0, (total, session) => total + session.minutes);
|
||||
.fold<int>(0, (total, session) => total + session.actualMinutes);
|
||||
}
|
||||
|
||||
int currentStreak(
|
||||
|
||||
@@ -3,9 +3,22 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/appearance_preference.dart';
|
||||
import '../models/language_preference.dart';
|
||||
|
||||
class FirstRunProgress {
|
||||
const FirstRunProgress({
|
||||
required this.onboardingComplete,
|
||||
required this.landingSetupComplete,
|
||||
});
|
||||
|
||||
final bool onboardingComplete;
|
||||
final bool landingSetupComplete;
|
||||
}
|
||||
|
||||
class AppPreferencesStore {
|
||||
static const _appearanceKey = 'focus_seed_appearance';
|
||||
static const _languageKey = 'focus_seed_language';
|
||||
static const _legacyWelcomeCompleteKey = 'focus_seed_welcome_complete';
|
||||
static const _onboardingCompleteKey = 'focus_seed_onboarding_complete';
|
||||
static const _landingSetupCompleteKey = 'focus_seed_landing_setup_complete';
|
||||
|
||||
Future<AppearancePreference> loadAppearance() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
@@ -26,4 +39,34 @@ class AppPreferencesStore {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
await preferences.setString(_languageKey, language.name);
|
||||
}
|
||||
|
||||
Future<FirstRunProgress> loadFirstRunProgress() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final legacyComplete =
|
||||
preferences.getBool(_legacyWelcomeCompleteKey) ?? false;
|
||||
final landingSetupComplete =
|
||||
preferences.getBool(_landingSetupCompleteKey) ?? legacyComplete;
|
||||
final onboardingComplete =
|
||||
preferences.getBool(_onboardingCompleteKey) ??
|
||||
(legacyComplete || landingSetupComplete);
|
||||
return FirstRunProgress(
|
||||
onboardingComplete: onboardingComplete,
|
||||
landingSetupComplete: landingSetupComplete,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveOnboardingComplete() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
await preferences.setBool(_onboardingCompleteKey, true);
|
||||
if (!preferences.containsKey(_landingSetupCompleteKey)) {
|
||||
await preferences.setBool(_landingSetupCompleteKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveLandingSetupComplete() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
await preferences.setBool(_onboardingCompleteKey, true);
|
||||
await preferences.setBool(_landingSetupCompleteKey, true);
|
||||
await preferences.setBool(_legacyWelcomeCompleteKey, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import 'package:drift/drift.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../core/date_keys.dart' as date_keys;
|
||||
import '../core/focus_durations.dart';
|
||||
import '../models/app_state.dart';
|
||||
import '../models/daily_action.dart';
|
||||
import '../models/focus_session.dart';
|
||||
import '../models/habit.dart';
|
||||
import '../models/mood.dart';
|
||||
import '../models/progress_note.dart';
|
||||
import '../models/reflection_entry.dart';
|
||||
import 'local/focus_seed_database.dart';
|
||||
|
||||
@@ -17,6 +20,7 @@ class FocusSeedStore {
|
||||
|
||||
static const _legacyStorageKey = 'focus_seed_state_v1';
|
||||
static const _legacyMigrationKey = 'focus_seed_state_v1_migrated_to_sqlite';
|
||||
static const _defaultFocusMinutesKey = 'default_focus_minutes';
|
||||
|
||||
final FocusSeedDatabase _database;
|
||||
|
||||
@@ -35,25 +39,56 @@ class FocusSeedStore {
|
||||
Future<void> save(AppState state) async {
|
||||
await _database.transaction(() async {
|
||||
await _database.delete(_database.habitLogRows).go();
|
||||
await _database.delete(_database.progressNoteRows).go();
|
||||
await _database.delete(_database.reflectionRows).go();
|
||||
await _database.delete(_database.focusSessionRows).go();
|
||||
await _database.delete(_database.recentLabelRows).go();
|
||||
await _database.delete(_database.dailyActionRows).go();
|
||||
await _database.delete(_database.habitRows).go();
|
||||
await _database.delete(_database.dailyIntentionRows).go();
|
||||
|
||||
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();
|
||||
await _database
|
||||
.into(_database.dailyIntentionRows)
|
||||
.insert(
|
||||
DailyIntentionRowsCompanion.insert(
|
||||
dateKey: state.anchorDateKey,
|
||||
intentionText: state.anchor,
|
||||
intentionText: anchorText,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final action in state.actions) {
|
||||
await _database
|
||||
.into(_database.dailyActionRows)
|
||||
.insert(_dailyActionCompanion(action));
|
||||
}
|
||||
|
||||
for (final label in state.recentLabels) {
|
||||
await _database
|
||||
.into(_database.recentLabelRows)
|
||||
.insert(
|
||||
RecentLabelRowsCompanion.insert(
|
||||
label: label,
|
||||
lastUsedAt: DateTime.now(),
|
||||
useCount: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (final (index, habit) in state.habits.indexed) {
|
||||
await _database
|
||||
.into(_database.habitRows)
|
||||
@@ -85,6 +120,12 @@ class FocusSeedStore {
|
||||
.insert(_focusSessionCompanion(session));
|
||||
}
|
||||
|
||||
for (final note in state.progressNotes) {
|
||||
await _database
|
||||
.into(_database.progressNoteRows)
|
||||
.insert(_progressNoteCompanion(note));
|
||||
}
|
||||
|
||||
for (final reflection in state.reflections) {
|
||||
await _database
|
||||
.into(_database.reflectionRows)
|
||||
@@ -120,6 +161,12 @@ class FocusSeedStore {
|
||||
..limit(1))
|
||||
.get();
|
||||
final latestIntention = intentionRows.firstOrNull;
|
||||
final dailyActionRows =
|
||||
await (_database.select(_database.dailyActionRows)..orderBy([
|
||||
(row) => OrderingTerm.asc(row.dateKey),
|
||||
(row) => OrderingTerm.asc(row.orderIndex),
|
||||
(row) => OrderingTerm.asc(row.createdAt),
|
||||
])).get();
|
||||
final habitRows =
|
||||
await (_database.select(_database.habitRows)
|
||||
..where((row) => row.archivedAt.isNull())
|
||||
@@ -134,18 +181,36 @@ class FocusSeedStore {
|
||||
final focusSessionRows =
|
||||
await (_database.select(_database.focusSessionRows)
|
||||
..orderBy([(row) => OrderingTerm.asc(row.endedAt)])).get();
|
||||
final progressNoteRows =
|
||||
await (_database.select(_database.progressNoteRows)
|
||||
..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get();
|
||||
final reflectionRows =
|
||||
await (_database.select(_database.reflectionRows)
|
||||
..orderBy([(row) => OrderingTerm.asc(row.createdAt)])).get();
|
||||
final recentLabelRows =
|
||||
await (_database.select(_database.recentLabelRows)..orderBy([
|
||||
(row) => OrderingTerm.desc(row.lastUsedAt),
|
||||
(row) => OrderingTerm.desc(row.useCount),
|
||||
])).get();
|
||||
final defaultFocusMinutes = await _readDefaultFocusMinutes();
|
||||
|
||||
final completedDatesByHabit = <String, Set<String>>{};
|
||||
for (final log in habitLogRows) {
|
||||
completedDatesByHabit.putIfAbsent(log.habitId, () => {}).add(log.dateKey);
|
||||
}
|
||||
|
||||
final actions = [
|
||||
for (final action in dailyActionRows) _dailyActionFromRow(action),
|
||||
];
|
||||
final progressNotes = [
|
||||
for (final note in progressNoteRows) _progressNoteFromRow(note),
|
||||
];
|
||||
final anchorDateKey = latestIntention?.dateKey ?? date_keys.todayKey();
|
||||
final anchor = latestIntention?.intentionText ?? '';
|
||||
|
||||
return AppState(
|
||||
anchor: latestIntention?.intentionText ?? '',
|
||||
anchorDateKey: latestIntention?.dateKey ?? date_keys.todayKey(),
|
||||
anchor: anchor,
|
||||
anchorDateKey: anchorDateKey,
|
||||
habits: [
|
||||
for (final habit in habitRows)
|
||||
Habit(
|
||||
@@ -160,7 +225,12 @@ class FocusSeedStore {
|
||||
FocusSession(
|
||||
id: session.id,
|
||||
minutes: session.plannedMinutes,
|
||||
actualSeconds: session.actualSeconds,
|
||||
completedAt: session.endedAt,
|
||||
intention: session.intentionText ?? '',
|
||||
actionId: session.actionId,
|
||||
actionTitleSnapshot:
|
||||
session.actionTitleSnapshot ?? session.intentionText,
|
||||
),
|
||||
],
|
||||
reflections: [
|
||||
@@ -176,9 +246,28 @@ class FocusSeedStore {
|
||||
createdAt: reflection.createdAt,
|
||||
),
|
||||
],
|
||||
actions: actions,
|
||||
progressNotes: progressNotes,
|
||||
recentLabels: [
|
||||
for (final label in recentLabelRows)
|
||||
if (label.label.trim().isNotEmpty) label.label.trim(),
|
||||
],
|
||||
defaultFocusMinutes: defaultFocusMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> _readDefaultFocusMinutes() async {
|
||||
final row =
|
||||
await (_database.select(_database.appSettingRows)..where(
|
||||
(row) => row.key.equals(_defaultFocusMinutesKey),
|
||||
)).getSingleOrNull();
|
||||
final value = int.tryParse(row?.value ?? '');
|
||||
if (value == null || !isFocusDurationPreset(value)) {
|
||||
return defaultFocusDurationMinutes;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacyJsonIfNeeded() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
if (preferences.getBool(_legacyMigrationKey) ?? false) {
|
||||
@@ -206,6 +295,12 @@ class FocusSeedStore {
|
||||
}
|
||||
|
||||
Future<bool> _hasDatabaseContent() async {
|
||||
final action =
|
||||
await (_database.select(_database.dailyActionRows)
|
||||
..limit(1)).getSingleOrNull();
|
||||
if (action != null) {
|
||||
return true;
|
||||
}
|
||||
final intention =
|
||||
await (_database.select(_database.dailyIntentionRows)
|
||||
..limit(1)).getSingleOrNull();
|
||||
@@ -224,20 +319,106 @@ class FocusSeedStore {
|
||||
if (session != null) {
|
||||
return true;
|
||||
}
|
||||
final progress =
|
||||
await (_database.select(_database.progressNoteRows)
|
||||
..limit(1)).getSingleOrNull();
|
||||
if (progress != null) {
|
||||
return true;
|
||||
}
|
||||
final reflection =
|
||||
await (_database.select(_database.reflectionRows)
|
||||
..limit(1)).getSingleOrNull();
|
||||
return reflection != null;
|
||||
}
|
||||
|
||||
DailyAction _dailyActionFromRow(DailyActionRow row) {
|
||||
return DailyAction(
|
||||
id: row.id,
|
||||
dateKey: row.dateKey,
|
||||
title: row.title,
|
||||
status: dailyActionStatusFromName(row.status),
|
||||
orderIndex: row.orderIndex,
|
||||
labels: _decodeLabels(row.labelsJson),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
archivedAt: row.archivedAt,
|
||||
source: row.source,
|
||||
);
|
||||
}
|
||||
|
||||
ProgressNote _progressNoteFromRow(ProgressNoteRow row) {
|
||||
return ProgressNote(
|
||||
id: row.id,
|
||||
dateKey: row.dateKey,
|
||||
actionId: row.actionId,
|
||||
sessionId: row.sessionId,
|
||||
note: row.note,
|
||||
outcome: progressOutcomeFromName(row.outcome),
|
||||
labels: _decodeLabels(row.labelsJson),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
DailyActionRowsCompanion _dailyActionCompanion(DailyAction action) {
|
||||
return DailyActionRowsCompanion.insert(
|
||||
id: action.id,
|
||||
dateKey: action.dateKey,
|
||||
title: action.title,
|
||||
status: action.status.name,
|
||||
orderIndex: action.orderIndex,
|
||||
labelsJson: jsonEncode(action.labels),
|
||||
createdAt: action.createdAt,
|
||||
updatedAt: action.updatedAt,
|
||||
archivedAt: Value(action.archivedAt),
|
||||
source: action.source,
|
||||
);
|
||||
}
|
||||
|
||||
ProgressNoteRowsCompanion _progressNoteCompanion(ProgressNote note) {
|
||||
return ProgressNoteRowsCompanion.insert(
|
||||
id: note.id,
|
||||
dateKey: note.dateKey,
|
||||
actionId: Value(note.actionId),
|
||||
sessionId: Value(note.sessionId),
|
||||
note: note.note,
|
||||
outcome: Value(note.outcome?.name),
|
||||
labelsJson: jsonEncode(note.labels),
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _decodeLabels(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List<Object?>) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final item in decoded)
|
||||
if (item is String && item.trim().isNotEmpty) item.trim(),
|
||||
];
|
||||
} on Object {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
FocusSessionRowsCompanion _focusSessionCompanion(FocusSession session) {
|
||||
final completedAt = session.completedAt;
|
||||
return FocusSessionRowsCompanion.insert(
|
||||
id: session.id,
|
||||
dateKey: date_keys.dateKey(completedAt),
|
||||
actionId: Value(session.actionId),
|
||||
actionTitleSnapshot: Value(
|
||||
session.actionTitle.trim().isEmpty ? null : session.actionTitle,
|
||||
),
|
||||
intentionText: Value(
|
||||
session.actionTitle.trim().isEmpty ? null : session.actionTitle,
|
||||
),
|
||||
plannedMinutes: session.minutes,
|
||||
actualSeconds: session.minutes * 60,
|
||||
startedAt: completedAt.subtract(Duration(minutes: session.minutes)),
|
||||
actualSeconds: session.actualSeconds,
|
||||
startedAt: completedAt.subtract(Duration(seconds: session.actualSeconds)),
|
||||
endedAt: completedAt,
|
||||
status: 'completed',
|
||||
);
|
||||
|
||||
@@ -26,6 +26,10 @@ class FocusSessionRows extends Table {
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get dateKey => text()();
|
||||
TextColumn get actionId => text().nullable().named('action_id')();
|
||||
TextColumn get actionTitleSnapshot =>
|
||||
text().nullable().named('action_title_snapshot')();
|
||||
TextColumn get intentionText => text().nullable().named('intention_text')();
|
||||
IntColumn get plannedMinutes => integer()();
|
||||
IntColumn get actualSeconds => integer()();
|
||||
DateTimeColumn get startedAt => dateTime()();
|
||||
@@ -36,6 +40,55 @@ class FocusSessionRows extends Table {
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class DailyActionRows extends Table {
|
||||
@override
|
||||
String get tableName => 'daily_actions';
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get dateKey => text()();
|
||||
TextColumn get title => text()();
|
||||
TextColumn get status => text()();
|
||||
IntColumn get orderIndex => integer().named('order_index')();
|
||||
TextColumn get labelsJson => text().named('labels_json')();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
DateTimeColumn get archivedAt => dateTime().nullable()();
|
||||
TextColumn get source => text()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class ProgressNoteRows extends Table {
|
||||
@override
|
||||
String get tableName => 'progress_notes';
|
||||
|
||||
TextColumn get id => text()();
|
||||
TextColumn get dateKey => text()();
|
||||
TextColumn get actionId => text().nullable().named('action_id')();
|
||||
TextColumn get sessionId => text().nullable().named('session_id')();
|
||||
TextColumn get note => text()();
|
||||
TextColumn get outcome => text().nullable()();
|
||||
TextColumn get labelsJson => text().named('labels_json')();
|
||||
DateTimeColumn get createdAt => dateTime()();
|
||||
DateTimeColumn get updatedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
|
||||
class RecentLabelRows extends Table {
|
||||
@override
|
||||
String get tableName => 'recent_labels';
|
||||
|
||||
TextColumn get label => text()();
|
||||
DateTimeColumn get lastUsedAt => dateTime()();
|
||||
IntColumn get useCount => integer()();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {label};
|
||||
}
|
||||
|
||||
class HabitRows extends Table {
|
||||
@override
|
||||
String get tableName => 'habits';
|
||||
@@ -92,7 +145,10 @@ class AppSettingRows extends Table {
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
DailyIntentionRows,
|
||||
DailyActionRows,
|
||||
FocusSessionRows,
|
||||
ProgressNoteRows,
|
||||
RecentLabelRows,
|
||||
HabitRows,
|
||||
HabitLogRows,
|
||||
ReflectionRows,
|
||||
@@ -104,13 +160,43 @@ class FocusSeedDatabase extends _$FocusSeedDatabase {
|
||||
: super(executor ?? _openConnection());
|
||||
|
||||
@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 {
|
||||
await transaction(() async {
|
||||
await delete(habitLogRows).go();
|
||||
await delete(progressNoteRows).go();
|
||||
await delete(reflectionRows).go();
|
||||
await delete(focusSessionRows).go();
|
||||
await delete(recentLabelRows).go();
|
||||
await delete(dailyActionRows).go();
|
||||
await delete(habitRows).go();
|
||||
await delete(dailyIntentionRows).go();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../application/focus_seed_controller.dart';
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../widgets/app_bottom_sheet.dart';
|
||||
import '../../widgets/app_empty_state.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../../widgets/loading_view.dart';
|
||||
import '../today/widgets/compact_habit_row.dart';
|
||||
|
||||
Future<void> showHabitManagerSheet(
|
||||
@@ -21,89 +25,63 @@ Future<void> showHabitManagerSheet(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
final state = controller.state;
|
||||
if (state == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final l10n = context.l10n;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
if (state == null) {
|
||||
return AppBottomSheetScaffold(
|
||||
title: l10n.habitsTitle,
|
||||
subtitle: l10n.habitsSubtitle,
|
||||
icon: Icons.add_task_rounded,
|
||||
accent: visuals.habit,
|
||||
expand: true,
|
||||
child: const LoadingView(),
|
||||
);
|
||||
}
|
||||
final today = todayKey();
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.habitsTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
Text(
|
||||
l10n.habitsSubtitle,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.addHabit,
|
||||
onPressed:
|
||||
return AppBottomSheetScaffold(
|
||||
title: l10n.habitsTitle,
|
||||
subtitle: l10n.habitsSubtitle,
|
||||
icon: Icons.add_task_rounded,
|
||||
accent: visuals.habit,
|
||||
expand: true,
|
||||
trailing: IconButton.filledTonal(
|
||||
tooltip: l10n.addHabit,
|
||||
onPressed:
|
||||
() => showHabitDialog(context, controller.addHabit),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
child:
|
||||
state.habits.isEmpty
|
||||
? _EmptyHabits(
|
||||
onAdd:
|
||||
() =>
|
||||
showHabitDialog(context, controller.addHabit),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child:
|
||||
state.habits.isEmpty
|
||||
? _EmptyHabits(
|
||||
onAdd:
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(0, 2, 0, 24),
|
||||
itemCount: state.habits.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final habit = state.habits[index];
|
||||
return CompactHabitRow(
|
||||
habit: habit,
|
||||
dateKey: today,
|
||||
onToggle: () => controller.toggleHabit(habit),
|
||||
onEdit:
|
||||
() => showHabitDialog(
|
||||
context,
|
||||
controller.addHabit,
|
||||
(value) =>
|
||||
controller.renameHabit(habit, value),
|
||||
initialValue: habit.title,
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 32),
|
||||
itemCount: state.habits.length,
|
||||
separatorBuilder: (_, _) => const Divider(),
|
||||
itemBuilder: (context, index) {
|
||||
final habit = state.habits[index];
|
||||
return CompactHabitRow(
|
||||
habit: habit,
|
||||
dateKey: today,
|
||||
onToggle: () => controller.toggleHabit(habit),
|
||||
onEdit:
|
||||
() => showHabitDialog(
|
||||
context,
|
||||
(value) => controller.renameHabit(
|
||||
habit,
|
||||
value,
|
||||
),
|
||||
initialValue: habit.title,
|
||||
),
|
||||
onDelete:
|
||||
() => confirmDeleteHabit(
|
||||
context,
|
||||
() => controller.deleteHabit(habit),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
onDelete:
|
||||
() => confirmDeleteHabit(
|
||||
context,
|
||||
() => controller.deleteHabit(habit),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -118,31 +96,17 @@ class _EmptyHabits extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.eco_outlined,
|
||||
size: 40,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.emptyHabitsTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: Text(context.l10n.addHabit),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
final l10n = context.l10n;
|
||||
return AppEmptyState(
|
||||
title: l10n.emptyHabitsTitle,
|
||||
body: l10n.emptyHabitPrompt,
|
||||
icon: Icons.add_task_rounded,
|
||||
actionLabel: l10n.addHabit,
|
||||
actionIcon: Icons.add_rounded,
|
||||
onAction: onAdd,
|
||||
accent: FocusSeedVisuals.of(context).habit,
|
||||
illustrationHeight: 86,
|
||||
padding: const EdgeInsets.all(28),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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;
|
||||
}
|
||||
+947
-557
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../application/focus_seed_controller.dart';
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../core/iterable_extensions.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../models/mood.dart';
|
||||
import '../../widgets/app_bottom_sheet.dart';
|
||||
import '../../widgets/app_busy_indicator.dart';
|
||||
import '../../widgets/surface_panel.dart';
|
||||
|
||||
Future<void> showReflectionSheet(
|
||||
BuildContext context,
|
||||
FocusSeedController controller,
|
||||
) {
|
||||
FocusSeedController controller, {
|
||||
String? dateKey,
|
||||
String contextTitle = '',
|
||||
}) {
|
||||
final state = controller.state;
|
||||
if (state == null) {
|
||||
return Future.value();
|
||||
}
|
||||
final existing =
|
||||
final targetDateKey = dateKey ?? todayKey();
|
||||
final title = contextTitle.trim();
|
||||
final existingReflection =
|
||||
state.reflections
|
||||
.where((entry) => entry.dateKey == todayKey())
|
||||
.where((entry) => entry.dateKey == targetDateKey)
|
||||
.firstOrNull;
|
||||
final legacyProgress =
|
||||
state.progressNotes
|
||||
.where((entry) => entry.dateKey == targetDateKey)
|
||||
.firstOrNull;
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -24,9 +36,15 @@ Future<void> showReflectionSheet(
|
||||
useSafeArea: true,
|
||||
builder:
|
||||
(context) => _ReflectionComposer(
|
||||
initialNote: existing?.note ?? '',
|
||||
initialMood: existing?.mood ?? Mood.clear,
|
||||
onSave: controller.saveReflection,
|
||||
initialNote: existingReflection?.note ?? legacyProgress?.note ?? '',
|
||||
contextTitle: title,
|
||||
dateKey: targetDateKey,
|
||||
onSave:
|
||||
(note) => controller.saveReflectionForDate(
|
||||
targetDateKey,
|
||||
note,
|
||||
existingReflection?.mood ?? Mood.clear,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -34,13 +52,15 @@ Future<void> showReflectionSheet(
|
||||
class _ReflectionComposer extends StatefulWidget {
|
||||
const _ReflectionComposer({
|
||||
required this.initialNote,
|
||||
required this.initialMood,
|
||||
required this.contextTitle,
|
||||
required this.dateKey,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final String initialNote;
|
||||
final Mood initialMood;
|
||||
final Future<bool> Function(String note, Mood mood) onSave;
|
||||
final String contextTitle;
|
||||
final String dateKey;
|
||||
final Future<bool> Function(String note) onSave;
|
||||
|
||||
@override
|
||||
State<_ReflectionComposer> createState() => _ReflectionComposerState();
|
||||
@@ -48,7 +68,6 @@ class _ReflectionComposer extends StatefulWidget {
|
||||
|
||||
class _ReflectionComposerState extends State<_ReflectionComposer> {
|
||||
late final TextEditingController _controller;
|
||||
late Mood _mood;
|
||||
bool _saving = false;
|
||||
String? _errorText;
|
||||
|
||||
@@ -56,7 +75,6 @@ class _ReflectionComposerState extends State<_ReflectionComposer> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.initialNote);
|
||||
_mood = widget.initialMood;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -75,7 +93,7 @@ class _ReflectionComposerState extends State<_ReflectionComposer> {
|
||||
_saving = true;
|
||||
_errorText = null;
|
||||
});
|
||||
final saved = await widget.onSave(_controller.text, _mood);
|
||||
final saved = await widget.onSave(_controller.text);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
@@ -92,72 +110,112 @@ class _ReflectionComposerState extends State<_ReflectionComposer> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
4,
|
||||
20,
|
||||
MediaQuery.viewInsetsOf(context).bottom + 24,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.reflectionSheetTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.reflectionSheetSubtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
return AppBottomSheetScaffold(
|
||||
title: l10n.reflectionSheetTitle,
|
||||
subtitle: l10n.reflectionSheetSubtitle,
|
||||
icon: Icons.edit_note_rounded,
|
||||
accent: visuals.reflection,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.contextTitle.isNotEmpty) ...[
|
||||
SurfacePanel(
|
||||
radius: 18,
|
||||
padding: const EdgeInsets.fromLTRB(12, 11, 12, 11),
|
||||
color: Color.alphaBlend(
|
||||
visuals.action.withValues(alpha: 0.045),
|
||||
scheme.surfaceContainerLowest,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.flag_outlined, color: visuals.action, size: 19),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.todayIntentionLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: visuals.action,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.contextTitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.18,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 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,
|
||||
autofocus: widget.initialNote.isEmpty,
|
||||
minLines: 4,
|
||||
maxLines: 7,
|
||||
maxLength: 280,
|
||||
scrollPadding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom + 32,
|
||||
),
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
hintText: l10n.reflectionHint,
|
||||
errorText: _errorText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon:
|
||||
_saving
|
||||
? const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
label: Text(_saving ? l10n.savingLabel : l10n.saveReflection),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon:
|
||||
_saving
|
||||
? AppBusyIndicator(semanticLabel: l10n.savingLabel)
|
||||
: const Icon(Icons.auto_awesome_outlined),
|
||||
label: Text(_saving ? l10n.savingLabel : l10n.saveReflection),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: visuals.reflection,
|
||||
foregroundColor:
|
||||
ThemeData.estimateBrightnessForColor(visuals.reflection) ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
: scheme.onSurface,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
textStyle: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../application/focus_seed_controller.dart';
|
||||
import '../../application/focus_timer_controller.dart';
|
||||
import '../../data/focus_seed_store.dart';
|
||||
import '../../data/focus_timer_session_store.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../models/appearance_preference.dart';
|
||||
import '../../models/language_preference.dart';
|
||||
import '../../widgets/loading_view.dart';
|
||||
import '../journey/journey_screen.dart';
|
||||
import '../../widgets/tactile_canvas.dart';
|
||||
import '../focus/focus_screen.dart';
|
||||
import '../me/me_screen.dart';
|
||||
import '../onboarding/landing_setup_screen.dart';
|
||||
import '../records/records_screen.dart';
|
||||
import '../today/today_screen.dart';
|
||||
|
||||
class FocusSeedHome extends StatefulWidget {
|
||||
@@ -20,6 +27,8 @@ class FocusSeedHome extends StatefulWidget {
|
||||
required this.showLanguageTools,
|
||||
required this.onLanguageChanged,
|
||||
this.focusSeedStore,
|
||||
this.showLandingSetupInitially = false,
|
||||
this.onLandingSetupComplete,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -29,6 +38,8 @@ class FocusSeedHome extends StatefulWidget {
|
||||
final bool showLanguageTools;
|
||||
final ValueChanged<LanguagePreference> onLanguageChanged;
|
||||
final FocusSeedStore? focusSeedStore;
|
||||
final bool showLandingSetupInitially;
|
||||
final VoidCallback? onLandingSetupComplete;
|
||||
|
||||
@override
|
||||
State<FocusSeedHome> createState() => _FocusSeedHomeState();
|
||||
@@ -38,6 +49,13 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
|
||||
FocusSeedController? _controller;
|
||||
FocusTimerController? _timerController;
|
||||
int _tabIndex = 0;
|
||||
late bool _landingSetupVisible;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_landingSetupVisible = widget.showLandingSetupInitially;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -50,14 +68,41 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
|
||||
defaultHabitTitles: localizedStarterHabits(context.l10n),
|
||||
);
|
||||
_timerController = FocusTimerController(
|
||||
onCompleted: (minutes) async {
|
||||
final saved = await _controller!.completeFocusSession(minutes);
|
||||
sessionStore: FocusTimerSessionStore(),
|
||||
onCompleted: (minutes, actualSeconds) async {
|
||||
final saved = await _controller!.completeFocusSession(
|
||||
minutes,
|
||||
actualSeconds: actualSeconds,
|
||||
);
|
||||
if (saved) {
|
||||
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
|
||||
@@ -109,63 +154,101 @@ class _FocusSeedHomeState extends State<FocusSeedHome> {
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness:
|
||||
isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor:
|
||||
theme.navigationBarTheme.backgroundColor ??
|
||||
theme.colorScheme.surface,
|
||||
systemNavigationBarIconBrightness:
|
||||
isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: IndexedStack(
|
||||
index: _tabIndex,
|
||||
children: [
|
||||
TodayScreen(
|
||||
controller: controller,
|
||||
timerController: timerController,
|
||||
),
|
||||
JourneyScreen(controller: controller),
|
||||
MeScreen(
|
||||
controller: controller,
|
||||
appearance: widget.appearance,
|
||||
onAppearanceChanged: widget.onAppearanceChanged,
|
||||
language: widget.language,
|
||||
showLanguageTools: widget.showLanguageTools,
|
||||
onLanguageChanged: widget.onLanguageChanged,
|
||||
),
|
||||
],
|
||||
void openMe() => setState(() => _tabIndex = 3);
|
||||
void completeLandingSetup({bool openFocus = false}) {
|
||||
widget.onLandingSetupComplete?.call();
|
||||
setState(() {
|
||||
_landingSetupVisible = false;
|
||||
if (openFocus) {
|
||||
_tabIndex = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: timerController,
|
||||
builder: (context, _) {
|
||||
final hideBottomNavigation =
|
||||
!_landingSetupVisible &&
|
||||
_tabIndex == 1 &&
|
||||
(timerController.isRunning || timerController.isPaused);
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness:
|
||||
isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor:
|
||||
theme.navigationBarTheme.backgroundColor ??
|
||||
theme.colorScheme.surface,
|
||||
systemNavigationBarIconBrightness:
|
||||
isDark ? Brightness.light : Brightness.dark,
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _tabIndex,
|
||||
onDestinationSelected: (index) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _tabIndex = index);
|
||||
},
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.eco_outlined),
|
||||
selectedIcon: const Icon(Icons.eco_rounded),
|
||||
label: context.l10n.navToday,
|
||||
child: Scaffold(
|
||||
body: TactileCanvas(
|
||||
child:
|
||||
_landingSetupVisible
|
||||
? LandingSetupScreen(
|
||||
controller: controller,
|
||||
timerController: timerController,
|
||||
onDone: completeLandingSetup,
|
||||
onStartFocus:
|
||||
() => completeLandingSetup(openFocus: true),
|
||||
)
|
||||
: SafeArea(
|
||||
child: IndexedStack(
|
||||
index: _tabIndex,
|
||||
children: [
|
||||
TodayScreen(
|
||||
controller: controller,
|
||||
timerController: timerController,
|
||||
onOpenSettings: openMe,
|
||||
onOpenFocus:
|
||||
() => setState(() => _tabIndex = 1),
|
||||
),
|
||||
FocusScreen(
|
||||
controller: controller,
|
||||
timerController: timerController,
|
||||
onBackToday:
|
||||
() => setState(() => _tabIndex = 0),
|
||||
),
|
||||
RecordsScreen(
|
||||
controller: controller,
|
||||
onStartToday:
|
||||
() => setState(() => _tabIndex = 0),
|
||||
),
|
||||
MeScreen(
|
||||
controller: controller,
|
||||
appearance: widget.appearance,
|
||||
onAppearanceChanged:
|
||||
widget.onAppearanceChanged,
|
||||
language: widget.language,
|
||||
showLanguageTools: widget.showLanguageTools,
|
||||
onLanguageChanged: widget.onLanguageChanged,
|
||||
onDefaultFocusMinutesChanged: (minutes) {
|
||||
if (!timerController.isRunning &&
|
||||
!timerController.isPaused) {
|
||||
timerController.selectMinutes(minutes);
|
||||
}
|
||||
},
|
||||
embedded: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar:
|
||||
_landingSetupVisible || hideBottomNavigation
|
||||
? null
|
||||
: _FocusSeedNavigationBar(
|
||||
selectedIndex: _tabIndex,
|
||||
onDestinationSelected: (index) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _tabIndex = index);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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) {
|
||||
return switch (error) {
|
||||
FocusSeedError.openFailed => l10n.openFailed,
|
||||
|
||||
@@ -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_timer_controller.dart';
|
||||
import '../../core/date_keys.dart';
|
||||
import '../../core/iterable_extensions.dart';
|
||||
import '../../l10n/l10n.dart';
|
||||
import '../../models/habit.dart';
|
||||
import '../../models/reflection_entry.dart';
|
||||
import '../../models/today_progress.dart';
|
||||
import '../../widgets/dialogs.dart';
|
||||
import '../../widgets/focus_seed_header.dart';
|
||||
import '../../widgets/surface_panel.dart';
|
||||
import '../../widgets/experience_motion.dart';
|
||||
import '../../widgets/workbench_ui.dart';
|
||||
import '../habits/habit_manager_sheet.dart';
|
||||
import '../reflection/reflection_sheet.dart';
|
||||
import 'widgets/daily_progress_rail.dart';
|
||||
import 'widgets/focus_session_card.dart';
|
||||
import 'widgets/habit_section.dart';
|
||||
import 'widgets/intention_card.dart';
|
||||
import 'widgets/reflection_prompt.dart';
|
||||
|
||||
class TodayScreen extends StatelessWidget {
|
||||
const TodayScreen({
|
||||
required this.controller,
|
||||
required this.timerController,
|
||||
required this.onOpenSettings,
|
||||
required this.onOpenFocus,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final FocusSeedController controller;
|
||||
final FocusTimerController timerController;
|
||||
final VoidCallback onOpenSettings;
|
||||
final VoidCallback onOpenFocus;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = controller.state!;
|
||||
final l10n = context.l10n;
|
||||
final now = DateTime.now();
|
||||
final today = todayKey();
|
||||
final progress = TodayProgress.fromState(state, today);
|
||||
final reflection =
|
||||
state.reflections.where((entry) => entry.dateKey == today).firstOrNull;
|
||||
final reflection = _reflectionForToday(state.reflections, today);
|
||||
final activeSession = timerController.isRunning || timerController.isPaused;
|
||||
|
||||
void editIntention() {
|
||||
showIntentionDialog(
|
||||
void startFocus() {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
timerController.start();
|
||||
onOpenFocus();
|
||||
}
|
||||
|
||||
void openReflection() {
|
||||
showReflectionSheet(
|
||||
context,
|
||||
controller.setTodayAnchor,
|
||||
initialValue: progress.intention,
|
||||
controller,
|
||||
dateKey: today,
|
||||
contextTitle: progress.intention,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
FocusSeedHeader(
|
||||
title: localizedGreeting(l10n, now),
|
||||
subtitle: localizedReadableDate(context, now),
|
||||
isSaving: controller.isSaving,
|
||||
WorkbenchTopBar(
|
||||
title: l10n.brandName,
|
||||
trailingIcon: Icons.lock_outline_rounded,
|
||||
trailingTooltip: l10n.privacyTitle,
|
||||
onTrailingPressed: () => showPrivacySheet(context),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
@@ -62,37 +73,46 @@ class TodayScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
IntentionCard(
|
||||
intention: progress.intention,
|
||||
onEdit: editIntention,
|
||||
StaggeredReveal(
|
||||
child: _TodayCommandPanel(
|
||||
progress: progress,
|
||||
timerController: timerController,
|
||||
activeSession: activeSession,
|
||||
onEditFocus:
|
||||
() => showIntentionDialog(
|
||||
context,
|
||||
controller.setTodayAnchor,
|
||||
initialValue: progress.intention,
|
||||
),
|
||||
onStartFocus: startFocus,
|
||||
onOpenFocus: onOpenFocus,
|
||||
onReflect: openReflection,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FocusSessionCard(
|
||||
controller: timerController,
|
||||
growthStage: progress.growthStage,
|
||||
hasIntention: progress.hasIntention,
|
||||
onSetIntention: editIntention,
|
||||
onReflect: () => showReflectionSheet(context, controller),
|
||||
StaggeredReveal(
|
||||
order: 1,
|
||||
child: _HabitWorkbenchSection(
|
||||
habits: state.habits,
|
||||
dateKey: today,
|
||||
onToggle: (habit) => unawaited(controller.toggleHabit(habit)),
|
||||
onAdd: () => showHabitDialog(context, controller.addHabit),
|
||||
onManage: () => showHabitManagerSheet(context, controller),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SurfacePanel(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
|
||||
child: DailyProgressRail(progress: progress),
|
||||
StaggeredReveal(
|
||||
order: 2,
|
||||
child: _ReflectionWorkbenchSection(
|
||||
reflection: reflection,
|
||||
onTap: openReflection,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
HabitSection(
|
||||
habits: state.habits,
|
||||
dateKey: today,
|
||||
onToggle: controller.toggleHabit,
|
||||
onAdd: () => showHabitDialog(context, controller.addHabit),
|
||||
onManage: () => showHabitManagerSheet(context, controller),
|
||||
StaggeredReveal(
|
||||
order: 3,
|
||||
child: _TodayLedgerStrip(progress: progress),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ReflectionPrompt(
|
||||
reflection: reflection,
|
||||
onTap: () => showReflectionSheet(context, controller),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -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 {
|
||||
const _InlineError({required this.message});
|
||||
|
||||
@@ -122,7 +704,7 @@ class _InlineError extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(kWorkbenchRadius),
|
||||
),
|
||||
child: Row(
|
||||
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/services.dart';
|
||||
|
||||
import '../../../app/app_theme.dart';
|
||||
import '../../../l10n/l10n.dart';
|
||||
import '../../../models/habit.dart';
|
||||
import '../../../widgets/app_motion.dart';
|
||||
import '../../../widgets/experience_motion.dart';
|
||||
import '../../../widgets/pressable_scale.dart';
|
||||
|
||||
class CompactHabitRow extends StatelessWidget {
|
||||
const CompactHabitRow({
|
||||
@@ -28,75 +33,119 @@ class CompactHabitRow extends StatelessWidget {
|
||||
button: true,
|
||||
checked: complete,
|
||||
label: habit.title,
|
||||
child: InkWell(
|
||||
onTap: onToggle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color:
|
||||
complete ? theme.colorScheme.primary : Colors.transparent,
|
||||
border: Border.all(
|
||||
color:
|
||||
complete
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outline,
|
||||
child: PressableScale(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onToggle();
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: AppMotion.duration(context, AppMotion.fast),
|
||||
curve: AppMotion.emphasized,
|
||||
margin: const EdgeInsets.symmetric(vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
complete
|
||||
? FocusSeedVisuals.of(
|
||||
context,
|
||||
).success.withValues(alpha: 0.055)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_HabitCheckControl(complete: complete),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: AppMotion.duration(context, AppMotion.fast),
|
||||
style:
|
||||
theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontWeight:
|
||||
complete ? FontWeight.w700 : FontWeight.w500,
|
||||
) ??
|
||||
const TextStyle(),
|
||||
child: Text(
|
||||
habit.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
if (onEdit != null || onDelete != null)
|
||||
PopupMenuButton<String>(
|
||||
tooltip: l10n.habitOptions,
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
onEdit?.call();
|
||||
} else if (value == 'delete') {
|
||||
onDelete?.call();
|
||||
}
|
||||
},
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Text(l10n.rename),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(l10n.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text(
|
||||
habit.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color:
|
||||
complete
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: theme.colorScheme.onSurface,
|
||||
decoration: complete ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onEdit != null || onDelete != null)
|
||||
PopupMenuButton<String>(
|
||||
tooltip: l10n.habitOptions,
|
||||
onSelected: (value) {
|
||||
if (value == 'edit') {
|
||||
onEdit?.call();
|
||||
} else if (value == 'delete') {
|
||||
onDelete?.call();
|
||||
}
|
||||
},
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(value: 'edit', child: Text(l10n.rename)),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(l10n.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 '../../../app/app_theme.dart';
|
||||
import '../../../l10n/l10n.dart';
|
||||
import '../../../models/today_progress.dart';
|
||||
import '../../../widgets/app_motion.dart';
|
||||
|
||||
class DailyProgressRail extends StatelessWidget {
|
||||
const DailyProgressRail({required this.progress, super.key});
|
||||
@@ -27,16 +29,10 @@ class DailyProgressRail extends StatelessWidget {
|
||||
icon: Icons.timer_outlined,
|
||||
complete: progress.hasFocus,
|
||||
),
|
||||
_ProgressItem(
|
||||
label: l10n.progressHabits,
|
||||
detail: '${progress.completedHabits}/${progress.totalHabits}',
|
||||
icon: Icons.eco_outlined,
|
||||
complete: progress.hasHabits,
|
||||
),
|
||||
_ProgressItem(
|
||||
label: l10n.progressReflect,
|
||||
detail: progress.hasReflection ? l10n.done : l10n.later,
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
icon: Icons.edit_note_rounded,
|
||||
complete: progress.hasReflection,
|
||||
),
|
||||
];
|
||||
@@ -60,71 +56,94 @@ class _HorizontalProgress extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final lastCompleteIndex = items.lastIndexWhere((item) => item.complete);
|
||||
final progress =
|
||||
lastCompleteIndex <= 0 ? 0.0 : lastCompleteIndex / (items.length - 1);
|
||||
return Semantics(
|
||||
label: context.l10n.dailyStepsComplete(
|
||||
items.where((item) => item.complete).length,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (index > 0)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 2,
|
||||
color:
|
||||
items[index - 1].complete
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final inset = constraints.maxWidth / (items.length * 2);
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned(
|
||||
left: inset,
|
||||
right: inset,
|
||||
top: 15,
|
||||
child: _ProgressTrack(
|
||||
color: scheme.outlineVariant.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: inset,
|
||||
right: inset,
|
||||
top: 15,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: progress.clamp(0.0, 1.0),
|
||||
child: _ProgressTrack(
|
||||
color: visuals.success,
|
||||
glow: visuals.success.withValues(alpha: 0.20),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in items)
|
||||
Expanded(
|
||||
child: Center(child: _ProgressDot(item: item)),
|
||||
),
|
||||
),
|
||||
_ProgressDot(item: items[index]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
items[index].label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
items[index].detail,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final item in items)
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
item.detail,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
fontSize: 10.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -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 {
|
||||
const _ProgressDot({required this.item});
|
||||
|
||||
@@ -169,29 +210,51 @@ class _ProgressDot extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color:
|
||||
item.complete
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceContainerLowest,
|
||||
border: Border.all(
|
||||
color:
|
||||
final scheme = theme.colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
return AnimatedScale(
|
||||
scale: item.complete ? 1.08 : 1,
|
||||
duration: AppMotion.duration(context, AppMotion.fast),
|
||||
curve: AppMotion.settle,
|
||||
child: AnimatedContainer(
|
||||
duration: AppMotion.duration(context, AppMotion.medium),
|
||||
curve: AppMotion.emphasized,
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: item.complete ? visuals.success : scheme.surfaceContainerLow,
|
||||
border: Border.all(
|
||||
color: item.complete ? visuals.success : scheme.outlineVariant,
|
||||
),
|
||||
boxShadow:
|
||||
item.complete
|
||||
? 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(
|
||||
item.complete ? Icons.check_rounded : item.icon,
|
||||
key: ValueKey('${item.label}-${item.complete}'),
|
||||
size: 15,
|
||||
color: item.complete ? scheme.onPrimary : scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
item.complete ? Icons.check_rounded : item.icon,
|
||||
size: 17,
|
||||
color:
|
||||
item.complete
|
||||
? 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 '../../../app/app_theme.dart';
|
||||
import '../../../l10n/l10n.dart';
|
||||
import '../../../models/habit.dart';
|
||||
import '../../../widgets/surface_panel.dart';
|
||||
@@ -24,10 +25,16 @@ class HabitSection extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final completed =
|
||||
habits.where((habit) => habit.completedDates.contains(dateKey)).length;
|
||||
final completion =
|
||||
habits.isEmpty ? 0.0 : (completed / habits.length).clamp(0.0, 1.0);
|
||||
return SurfacePanel(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 12, 8),
|
||||
tone: SurfacePanelTone.paper,
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 14, 10),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
@@ -38,12 +45,14 @@ class HabitSection extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
l10n.todaysHabits,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.completedCount(completed, habits.length),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
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)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 16, 4, 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.eco_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Icon(Icons.eco_outlined, color: visuals.habit),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(l10n.emptyHabitPrompt)),
|
||||
TextButton(onPressed: onAdd, child: Text(l10n.add)),
|
||||
@@ -79,7 +87,7 @@ class HabitSection extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
for (var index = 0; index < habits.take(4).length; index++) ...[
|
||||
if (index > 0) const Divider(),
|
||||
if (index > 0) const Divider(indent: 48),
|
||||
CompactHabitRow(
|
||||
habit: habits[index],
|
||||
dateKey: dateKey,
|
||||
@@ -87,7 +95,7 @@ class HabitSection extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
if (habits.length > 4) ...[
|
||||
const Divider(),
|
||||
const Divider(indent: 48),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
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,75 +1,198 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../app/app_theme.dart';
|
||||
import '../../../l10n/l10n.dart';
|
||||
import '../../../models/today_progress.dart';
|
||||
import '../../../widgets/surface_panel.dart';
|
||||
|
||||
class IntentionCard extends StatelessWidget {
|
||||
const IntentionCard({
|
||||
required this.intention,
|
||||
required this.progress,
|
||||
required this.onEdit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String intention;
|
||||
final TodayProgress progress;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final l10n = context.l10n;
|
||||
final isEmpty = intention.isEmpty;
|
||||
final body =
|
||||
isEmpty
|
||||
? l10n.todayLoopStartBody
|
||||
: progress.completedSteps >= 3
|
||||
? l10n.todayLoopDoneBody
|
||||
: l10n.todayLoopActiveBody;
|
||||
return SurfacePanel(
|
||||
elevated: !isEmpty,
|
||||
tone: SurfacePanelTone.paper,
|
||||
onTap: onEdit,
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 15),
|
||||
color: Color.alphaBlend(
|
||||
visuals.action.withValues(alpha: isEmpty ? 0.035 : 0.025),
|
||||
theme.colorScheme.surfaceContainerLowest,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.11),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(Icons.flag_outlined, color: theme.colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.todayIntentionLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 5,
|
||||
height: 58,
|
||||
decoration: BoxDecoration(
|
||||
color: visuals.action,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.24),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
isEmpty ? l10n.chooseOneOutcome : intention,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontSize: 18,
|
||||
color:
|
||||
isEmpty
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.todayIntentionLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: visuals.action,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
isEmpty ? l10n.chooseOneOutcome : intention,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.08,
|
||||
color:
|
||||
isEmpty
|
||||
? theme.colorScheme.onSurfaceVariant
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
body,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (isEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.flag_outlined),
|
||||
label: Text(l10n.setTodaysIntention),
|
||||
),
|
||||
],
|
||||
if (!isEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_StatusChip(
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
label: l10n.todayLoopProgress(progress.completedSteps),
|
||||
color: visuals.success,
|
||||
),
|
||||
_StatusChip(
|
||||
icon: Icons.timer_outlined,
|
||||
label: l10n.minutesShort(
|
||||
progress.focusMinutes == 0 ? 10 : progress.focusMinutes,
|
||||
),
|
||||
color: visuals.focus,
|
||||
),
|
||||
_StatusChip(
|
||||
icon: Icons.edit_note_rounded,
|
||||
label:
|
||||
progress.hasReflection
|
||||
? l10n.reflectionCaptured
|
||||
: l10n.reflectionAction,
|
||||
color: visuals.reflection,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
isEmpty ? Icons.add_rounded : Icons.edit_outlined,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
size: 21,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 isComplete = reflection != null;
|
||||
return SurfacePanel(
|
||||
borderColor: visuals.reflection.withValues(alpha: 0.35),
|
||||
tone: SurfacePanelTone.paper,
|
||||
color: visuals.reflection.withValues(alpha: 0.06),
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
|
||||
+125
-57
@@ -5,11 +5,12 @@
|
||||
"saving": "Saving",
|
||||
"local": "Local",
|
||||
"navToday": "Today",
|
||||
"navJourney": "Journey",
|
||||
"navRecords": "Records",
|
||||
"navMe": "Me",
|
||||
"tryAgain": "Try again",
|
||||
"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.",
|
||||
"goodMorning": "Good morning",
|
||||
"goodDay": "Good day",
|
||||
@@ -33,11 +34,23 @@
|
||||
"timerRunning": "Deep work in progress",
|
||||
"timerPaused": "Paused - your place is kept",
|
||||
"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",
|
||||
"setTodaysIntention": "Set today's focus",
|
||||
"pause": "Pause",
|
||||
"end": "End",
|
||||
"completeSession": "Complete session",
|
||||
"continueSession": "Continue session",
|
||||
"resume": "Resume",
|
||||
"startMinutes": "Start {minutes} min",
|
||||
"@startMinutes": {
|
||||
@@ -63,7 +76,7 @@
|
||||
"manageHabits": "Manage habits",
|
||||
"addHabit": "Add habit",
|
||||
"add": "Add",
|
||||
"emptyHabitPrompt": "Add one repeatable habit.",
|
||||
"emptyHabitPrompt": "Keep this optional and small.",
|
||||
"viewAllHabits": "View all {count}",
|
||||
"@viewAllHabits": {
|
||||
"placeholders": {
|
||||
@@ -90,7 +103,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dailyStepsComplete": "{completed}/4 daily steps complete",
|
||||
"dailyStepsComplete": "{completed}/3 daily steps complete",
|
||||
"@dailyStepsComplete": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
@@ -101,16 +114,17 @@
|
||||
"habitOptions": "Habit options",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"habitsTitle": "Habits",
|
||||
"habitsSubtitle": "Keep it small and repeatable.",
|
||||
"emptyHabitsTitle": "Add a small habit",
|
||||
"habitsTitle": "Supporting habits",
|
||||
"habitsSubtitle": "Optional repeatable steps. Today's focus still comes first.",
|
||||
"emptyHabitsTitle": "Add a supporting habit",
|
||||
"addHabitTitle": "Add habit",
|
||||
"renameHabitTitle": "Rename habit",
|
||||
"habitHint": "Example: Read 10 pages",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"setIntentionTitle": "Set intention",
|
||||
"editIntentionTitle": "Edit intention",
|
||||
"textEntryRequired": "Write a short line first.",
|
||||
"setIntentionTitle": "Set today's focus",
|
||||
"editIntentionTitle": "Edit today's focus",
|
||||
"intentionHint": "What matters most today?",
|
||||
"deleteHabitTitle": "Delete habit?",
|
||||
"deleteHabitBody": "Past completion records for this habit will be removed.",
|
||||
@@ -124,20 +138,22 @@
|
||||
"deleteAll": "Delete all",
|
||||
"privacyTitle": "Privacy & data",
|
||||
"privacyBodyOne": "Focus Seed stores your intention, habits, focus sessions, reflections, and settings locally on this device. This version has no sign-in, cloud sync, ads, analytics SDK, or billing SDK.",
|
||||
"privacyBodyTwo": "You can clear today's progress or delete all local data from 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.",
|
||||
"reflectionSaveError": "Could not save. Please try again.",
|
||||
"reflectionSheetTitle": "How did it go?",
|
||||
"reflectionSheetSubtitle": "Keep what worked. Adjust one thing.",
|
||||
"reflectionHint": "One useful sentence is enough.",
|
||||
"reflectionSheetTitle": "Leave one progress note",
|
||||
"reflectionSheetSubtitle": "Save what moved forward so next time has a clear start.",
|
||||
"reflectionHint": "What moved forward?",
|
||||
"reflectionMoodLabel": "Mood is optional",
|
||||
"savingLabel": "Saving",
|
||||
"saveReflection": "Save reflection",
|
||||
"saveReflection": "Save note",
|
||||
"moodCalm": "Calm",
|
||||
"moodClear": "Clear",
|
||||
"moodProud": "Proud",
|
||||
"moodTired": "Tired",
|
||||
"meTitle": "Your garden",
|
||||
"meSubtitle": "Preferences and records stay on this device.",
|
||||
"meTitle": "Me",
|
||||
"meSubtitle": "Manage your rhythm, appearance, and records.",
|
||||
"appSettingsTitle": "App settings",
|
||||
"appearance": "Theme",
|
||||
"system": "System",
|
||||
"light": "Light",
|
||||
@@ -148,14 +164,19 @@
|
||||
"languageEnglish": "English",
|
||||
"languageChinese": "Chinese",
|
||||
"preferencesTitle": "Preferences",
|
||||
"preferencesDetail": "Make it feel right",
|
||||
"preferencesDetail": "Theme, language, and default focus",
|
||||
"defaultFocusDuration": "Default focus",
|
||||
"defaultFocusDurationDetail": "Next session",
|
||||
"localData": "Local records",
|
||||
"localDataDetail": "Private on device",
|
||||
"personalGardenTitle": "Your focus garden",
|
||||
"personalGardenSubtitle": "Every small session leaves a trace you can return to.",
|
||||
"personalGardenTitle": "My accumulation",
|
||||
"personalGardenSubtitle": "Your local action records, progress notes, and app controls stay on this device.",
|
||||
"focusDataLabel": "Focus",
|
||||
"habitsDataLabel": "Habits",
|
||||
"notesDataLabel": "Notes",
|
||||
"notesDataLabel": "Progress",
|
||||
"totalActionTimeLabel": "Action time",
|
||||
"recordsCountLabel": "Records",
|
||||
"progressNotesCountLabel": "Progress notes",
|
||||
"clearToday": "Clear today",
|
||||
"clearTodaySubtitle": "Reset only today's loop.",
|
||||
"privacySubtitle": "No account or cloud sync.",
|
||||
@@ -163,24 +184,22 @@
|
||||
"deleteAllLocalDataSubtitle": "Remove this device's Focus Seed records.",
|
||||
"todayDataCleared": "Today's data cleared",
|
||||
"localDataCleared": "Local data deleted",
|
||||
"journeyTitle": "Progress",
|
||||
"journeyOverviewLabel": "TODAY'S LOOP",
|
||||
"journeyOverviewStartTitle": "Start with one clear focus",
|
||||
"journeyOverviewStartBody": "Pick the thing that would make today feel finished.",
|
||||
"journeyOverviewActiveTitle": "Your day is taking shape",
|
||||
"journeyOverviewActiveBody": "Keep the loop moving: focus, habits, then a short reflection.",
|
||||
"journeyOverviewDoneTitle": "Loop complete",
|
||||
"journeyOverviewDoneBody": "You left a clean record for future you.",
|
||||
"journeyOverviewProgress": "{completed}/4 done",
|
||||
"@journeyOverviewProgress": {
|
||||
"todayLoopLabel": "TODAY'S LOOP",
|
||||
"todayLoopStartTitle": "Start with one clear focus",
|
||||
"todayLoopStartBody": "Pick the one result that matters today.",
|
||||
"todayLoopActiveTitle": "Your day is taking shape",
|
||||
"todayLoopActiveBody": "Focus once, then leave one useful note.",
|
||||
"todayLoopDoneTitle": "Progress saved",
|
||||
"todayLoopDoneBody": "You moved today forward and left a clear place to continue.",
|
||||
"todayLoopProgress": "{completed}/3 done",
|
||||
"@todayLoopProgress": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewNow": "Reflect now",
|
||||
"journeyStatsTitle": "7-day rhythm",
|
||||
"reviewNow": "Write a note",
|
||||
"weekFocusLabel": "Focus",
|
||||
"streakLabel": "Streak",
|
||||
"activeDaysLabel": "Active",
|
||||
@@ -207,9 +226,28 @@
|
||||
"focusHistory": "Focus",
|
||||
"notesHistory": "Notes",
|
||||
"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",
|
||||
"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",
|
||||
"sessionsTotal": "{count} sessions total",
|
||||
"@sessionsTotal": {
|
||||
@@ -250,32 +288,62 @@
|
||||
}
|
||||
},
|
||||
"reflectionCaptured": "Today's lesson is captured",
|
||||
"reflectionAction": "Close the loop with one sentence",
|
||||
"growthTitle": "Growth",
|
||||
"growthFlowerOpen": "Today's flower is open",
|
||||
"growthLocked": "Unlocked after reflection",
|
||||
"sevenDayGrowth": "7-day growth",
|
||||
"sevenDayGrowthSubtitle": "Small actions become visible over time.",
|
||||
"growthSeed": "seed",
|
||||
"growthSprout": "sprout",
|
||||
"growthLeaves": "leaves",
|
||||
"growthFlower": "flower",
|
||||
"growthDaySemantic": "{date}, {stage}",
|
||||
"@growthDaySemantic": {
|
||||
"reflectionAction": "Write one sentence to close today",
|
||||
"nextActionTitle": "Next action",
|
||||
"nextActionSetFocusTitle": "Write today's focus",
|
||||
"nextActionSetFocusBody": "One short line is enough. You can adjust it later.",
|
||||
"nextActionStartFocusTitle": "Start 10 minutes",
|
||||
"nextActionStartFocusBody": "Writing the action is optional. Start first.",
|
||||
"nextActionStartActionBody": "Move this forward in one small session, then leave one progress note.",
|
||||
"nextActionHabitsTitle": "Finish one small habit",
|
||||
"nextActionHabitsBody": "Complete one repeatable step while it is still easy.",
|
||||
"nextActionReflectTitle": "Write a quick note",
|
||||
"nextActionReflectBody": "Record what moved forward so the next start is easier.",
|
||||
"nextActionCompleteTitle": "Today moved forward",
|
||||
"nextActionCompleteBody": "Your note is saved. Continue only if it still feels useful.",
|
||||
"nextActionContinueFocus": "Focus {minutes} min again",
|
||||
"@nextActionContinueFocus": {
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "Object"
|
||||
},
|
||||
"stage": {
|
||||
"type": "Object"
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"growthSeedSemantic": "A seed waiting for today's intention",
|
||||
"growthSproutSemantic": "A sprout growing from today's intention",
|
||||
"growthLeavesSemantic": "A young plant growing with today's progress",
|
||||
"growthFlowerSemantic": "A flower marking a complete daily cycle",
|
||||
"habitTemplatesTitle": "Useful starts",
|
||||
"habitTemplatesBody": "Add one tiny repeatable action.",
|
||||
"habitTemplateStretch": "2 min stretch",
|
||||
"habitTemplateRead": "Read 10 pages",
|
||||
"habitTemplateWalk": "Walk outside",
|
||||
"habitTemplateNoScroll": "No-scroll lunch",
|
||||
"languageDetail": "Interface language",
|
||||
"recordsAndPrivacyTitle": "Records & privacy",
|
||||
"recordsAndPrivacyBody": "Your history stays local. Keep, reset, or delete it when needed.",
|
||||
"welcomeTrustLabel": "TODAY",
|
||||
"welcomeTitle": "Plant just one thing each day.",
|
||||
"welcomeBody": "Start with today's most important thing.",
|
||||
"welcomeNext": "The next step starts here.",
|
||||
"onboardingFocusBadge": "FOCUS",
|
||||
"onboardingFocusTitle": "Protect 10 minutes first.",
|
||||
"onboardingFocusBody": "It does not have to be long. Starting creates progress.",
|
||||
"onboardingFocusNext": "Keep this one thing in front of you while you focus.",
|
||||
"onboardingReflectBadge": "NOTE",
|
||||
"onboardingReflectTitle": "Leave one progress note.",
|
||||
"onboardingReflectBody": "When you finish, write one line so you know where to continue next time.",
|
||||
"onboardingReflectNext": "Now set today's focus.",
|
||||
"onboardingNext": "Continue",
|
||||
"welcomeStart": "Set today's focus",
|
||||
"welcomeSkip": "Skip",
|
||||
"landingTitle": "Start with 10 minutes.",
|
||||
"landingBody": "Writing is optional. Start first.",
|
||||
"landingFocusLabel": "What do you want to do first?",
|
||||
"landingFocusEntry": "Write down what you will do",
|
||||
"landingFocusHint": "Example: finish the launch checklist",
|
||||
"customFocusDuration": "Custom length",
|
||||
"customFocusDurationHint": "Enter 1-180 minutes",
|
||||
"minutesInputLabel": "Minutes",
|
||||
"landingStart": "Start 10 minutes",
|
||||
"landingSkip": "Enter Today",
|
||||
"defaultHabitPlanTask": "Plan one clear task",
|
||||
"defaultHabitFocus": "Focus for 25 minutes",
|
||||
"defaultHabitFocus": "Focus for 10 minutes",
|
||||
"defaultHabitReflect": "Write one-line reflection"
|
||||
}
|
||||
|
||||
+238
-170
@@ -2,19 +2,20 @@
|
||||
"@@locale": "zh",
|
||||
"appTitle": "Focus Seed",
|
||||
"brandName": "Focus Seed",
|
||||
"saving": "\u4fdd\u5b58\u4e2d",
|
||||
"local": "\u672c\u5730",
|
||||
"navToday": "\u4eca\u65e5",
|
||||
"navJourney": "\u8fdb\u5c55",
|
||||
"navMe": "\u6211\u7684",
|
||||
"tryAgain": "\u91cd\u8bd5",
|
||||
"couldNotOpenApp": "\u65e0\u6cd5\u6253\u5f00 Focus Seed\u3002",
|
||||
"openFailed": "\u672c\u5730\u6570\u636e\u6253\u5f00\u5931\u8d25\u3002",
|
||||
"saveFailed": "\u4fdd\u5b58\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002",
|
||||
"goodMorning": "\u65e9\u4e0a\u597d",
|
||||
"goodDay": "\u4eca\u5929\u597d",
|
||||
"goodEvening": "\u665a\u4e0a\u597d",
|
||||
"goodNight": "\u591c\u6df1\u4e86",
|
||||
"saving": "保存中",
|
||||
"local": "本地",
|
||||
"navToday": "今日",
|
||||
"navRecords": "记录",
|
||||
"navMe": "我的",
|
||||
"tryAgain": "重试",
|
||||
"couldNotOpenApp": "无法打开 Focus Seed。",
|
||||
"loadingLocalData": "正在读取本地数据",
|
||||
"openFailed": "本地数据打开失败。",
|
||||
"saveFailed": "保存失败,请重试。",
|
||||
"goodMorning": "早上好",
|
||||
"goodDay": "今天好",
|
||||
"goodEvening": "晚上好",
|
||||
"goodNight": "夜深了",
|
||||
"readableDate": "{date} - {weekday}",
|
||||
"@readableDate": {
|
||||
"placeholders": {
|
||||
@@ -26,20 +27,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"todayIntentionLabel": "\u4eca\u65e5\u91cd\u70b9",
|
||||
"chooseOneOutcome": "\u5199\u4e0b\u4eca\u5929\u7684\u91cd\u70b9",
|
||||
"focusLabel": "\u4e13\u6ce8",
|
||||
"timerReady": "\u51c6\u5907\u597d\u4e86\u5c31\u5f00\u59cb",
|
||||
"timerRunning": "\u4e13\u6ce8\u4e2d",
|
||||
"timerPaused": "\u5df2\u6682\u505c",
|
||||
"timerCompleted": "\u5df2\u5b8c\u6210",
|
||||
"reflectOneMinute": "\u590d\u76d8 1 \u5206\u949f",
|
||||
"startAnotherSession": "\u518d\u6765\u4e00\u8f6e",
|
||||
"setTodaysIntention": "\u8bbe\u7f6e\u4eca\u65e5\u91cd\u70b9",
|
||||
"pause": "\u6682\u505c",
|
||||
"end": "\u7ed3\u675f",
|
||||
"resume": "\u7ee7\u7eed",
|
||||
"startMinutes": "\u5f00\u59cb {minutes} \u5206\u949f",
|
||||
"todayIntentionLabel": "今日重点",
|
||||
"chooseOneOutcome": "写下今天的重点",
|
||||
"focusLabel": "专注",
|
||||
"timerReady": "准备好了就开始",
|
||||
"timerRunning": "专注中",
|
||||
"timerPaused": "已暂停",
|
||||
"timerCompleted": "已完成",
|
||||
"focusCurrentIntention": "今日重点:{intention}",
|
||||
"@focusCurrentIntention": {
|
||||
"placeholders": {
|
||||
"intention": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"focusNoIntentionTitle": "先开始这一段",
|
||||
"reflectOneMinute": "写一句进展",
|
||||
"startFocus": "开始专注",
|
||||
"startAnotherSession": "再来一轮",
|
||||
"setTodaysIntention": "设置今日重点",
|
||||
"pause": "暂停",
|
||||
"end": "结束",
|
||||
"completeSession": "完成本轮",
|
||||
"continueSession": "继续本轮",
|
||||
"resume": "继续",
|
||||
"startMinutes": "开始 {minutes} 分钟",
|
||||
"@startMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
@@ -47,9 +60,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"resetTimer": "\u91cd\u7f6e\u8ba1\u65f6",
|
||||
"todaysHabits": "\u4eca\u65e5\u4e60\u60ef",
|
||||
"completedCount": "{completed}/{total} \u5b8c\u6210",
|
||||
"resetTimer": "重置计时",
|
||||
"todaysHabits": "今日习惯",
|
||||
"completedCount": "{completed}/{total} 完成",
|
||||
"@completedCount": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
@@ -60,11 +73,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"manageHabits": "\u7ba1\u7406\u4e60\u60ef",
|
||||
"addHabit": "\u6dfb\u52a0\u4e60\u60ef",
|
||||
"add": "\u6dfb\u52a0",
|
||||
"emptyHabitPrompt": "\u6dfb\u52a0\u4e00\u4e2a\u53ef\u91cd\u590d\u7684\u5c0f\u4e60\u60ef\u3002",
|
||||
"viewAllHabits": "\u67e5\u770b\u5168\u90e8 {count} \u4e2a",
|
||||
"manageHabits": "管理习惯",
|
||||
"addHabit": "添加习惯",
|
||||
"add": "添加",
|
||||
"emptyHabitPrompt": "保持可选,也保持很小。",
|
||||
"viewAllHabits": "查看全部 {count} 个",
|
||||
"@viewAllHabits": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -72,17 +85,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"reflectionSaved": "\u590d\u76d8\u5df2\u4fdd\u5b58",
|
||||
"reflectionPromptTitle": "\u590d\u76d8\u4e00\u4e0b",
|
||||
"reflectionPromptBody": "\u5199\u4e00\u53e5\u6709\u7528\u7684\u603b\u7ed3\u3002",
|
||||
"progressIntention": "\u76ee\u6807",
|
||||
"progressFocus": "\u4e13\u6ce8",
|
||||
"progressHabits": "\u4e60\u60ef",
|
||||
"progressReflect": "\u590d\u76d8",
|
||||
"done": "\u5b8c\u6210",
|
||||
"next": "\u4e0b\u4e00\u6b65",
|
||||
"later": "\u7a0d\u540e",
|
||||
"minutesShort": "{minutes} \u5206\u949f",
|
||||
"reflectionSaved": "复盘已保存",
|
||||
"reflectionPromptTitle": "复盘一下",
|
||||
"reflectionPromptBody": "写一句有用的总结。",
|
||||
"progressIntention": "目标",
|
||||
"progressFocus": "专注",
|
||||
"progressHabits": "习惯",
|
||||
"progressReflect": "复盘",
|
||||
"done": "完成",
|
||||
"next": "下一步",
|
||||
"later": "稍后",
|
||||
"minutesShort": "{minutes} 分钟",
|
||||
"@minutesShort": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
@@ -90,7 +103,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dailyStepsComplete": "\u4eca\u65e5\u5b8c\u6210 {completed}/4",
|
||||
"dailyStepsComplete": "今日完成 {completed}/3",
|
||||
"@dailyStepsComplete": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
@@ -98,94 +111,100 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"habitOptions": "\u4e60\u60ef\u9009\u9879",
|
||||
"rename": "\u91cd\u547d\u540d",
|
||||
"delete": "\u5220\u9664",
|
||||
"habitsTitle": "\u4e60\u60ef",
|
||||
"habitsSubtitle": "\u5c11\u91cf\u3001\u53ef\u91cd\u590d\u3002",
|
||||
"emptyHabitsTitle": "\u6dfb\u52a0\u5c0f\u4e60\u60ef",
|
||||
"addHabitTitle": "\u6dfb\u52a0\u4e60\u60ef",
|
||||
"renameHabitTitle": "\u91cd\u547d\u540d\u4e60\u60ef",
|
||||
"habitHint": "\u4f8b\u5982\uff1a\u8bfb 10 \u9875\u4e66",
|
||||
"cancel": "\u53d6\u6d88",
|
||||
"save": "\u4fdd\u5b58",
|
||||
"setIntentionTitle": "\u8bbe\u7f6e\u76ee\u6807",
|
||||
"editIntentionTitle": "\u7f16\u8f91\u76ee\u6807",
|
||||
"intentionHint": "\u4eca\u5929\u6700\u91cd\u8981\u7684\u662f\u4ec0\u4e48\uff1f",
|
||||
"deleteHabitTitle": "\u5220\u9664\u4e60\u60ef\uff1f",
|
||||
"deleteHabitBody": "\u8fd9\u4e2a\u4e60\u60ef\u7684\u5386\u53f2\u5b8c\u6210\u8bb0\u5f55\u4e5f\u4f1a\u5220\u9664\u3002",
|
||||
"close": "\u5173\u95ed",
|
||||
"copy": "\u590d\u5236",
|
||||
"clearTodayDataTitle": "\u6e05\u7406\u4eca\u65e5\u6570\u636e\uff1f",
|
||||
"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",
|
||||
"clearAllDataTitle": "\u5220\u9664\u5168\u90e8\u672c\u5730\u6570\u636e\uff1f",
|
||||
"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",
|
||||
"clear": "\u6e05\u9664",
|
||||
"deleteAll": "\u5168\u90e8\u5220\u9664",
|
||||
"privacyTitle": "\u9690\u79c1\u4e0e\u6570\u636e",
|
||||
"privacyBodyOne": "Focus Seed \u53ea\u5728\u672c\u673a\u4fdd\u5b58\u76ee\u6807\u3001\u4e60\u60ef\u3001\u4e13\u6ce8\u8bb0\u5f55\u3001\u590d\u76d8\u548c\u8bbe\u7f6e\u3002\u5f53\u524d\u7248\u672c\u6ca1\u6709\u767b\u5f55\u3001\u4e91\u540c\u6b65\u3001\u5e7f\u544a\u3001\u5206\u6790 SDK \u6216\u4ed8\u8d39 SDK\u3002",
|
||||
"privacyBodyTwo": "\u4f60\u53ef\u4ee5\u968f\u65f6\u6e05\u7406\u4eca\u65e5\u8fdb\u5ea6\uff0c\u6216\u5220\u9664\u672c\u673a\u7684\u5168\u90e8\u672c\u5730\u6570\u636e\u3002",
|
||||
"reflectionEmptyError": "\u5199\u4e00\u53e5\u771f\u5b9e\u7684\u603b\u7ed3\u3002",
|
||||
"reflectionSaveError": "\u4fdd\u5b58\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002",
|
||||
"reflectionSheetTitle": "\u590d\u76d8\u4e00\u4e0b",
|
||||
"reflectionSheetSubtitle": "\u4fdd\u7559\u6709\u6548\u7684\uff0c\u8c03\u6574\u4e00\u4e2a\u70b9\u3002",
|
||||
"reflectionHint": "\u4e00\u53e5\u6709\u7528\u7684\u8bdd\u5c31\u591f\u3002",
|
||||
"savingLabel": "\u4fdd\u5b58\u4e2d",
|
||||
"saveReflection": "\u4fdd\u5b58\u590d\u76d8",
|
||||
"moodCalm": "\u5e73\u9759",
|
||||
"moodClear": "\u6e05\u9192",
|
||||
"moodProud": "\u6ee1\u610f",
|
||||
"moodTired": "\u75b2\u60eb",
|
||||
"meTitle": "\u6211\u7684\u82b1\u56ed",
|
||||
"meSubtitle": "\u504f\u597d\u548c\u8bb0\u5f55\u90fd\u53ea\u4fdd\u5b58\u5728\u672c\u673a\u3002",
|
||||
"appearance": "\u4e3b\u9898",
|
||||
"system": "\u7cfb\u7edf",
|
||||
"light": "\u6d45\u8272",
|
||||
"dark": "\u6df1\u8272",
|
||||
"language": "\u8bed\u8a00",
|
||||
"developmentOnly": "\u4ec5\u5f00\u53d1\u73af\u5883",
|
||||
"languageDefault": "\u9ed8\u8ba4",
|
||||
"habitOptions": "习惯选项",
|
||||
"rename": "重命名",
|
||||
"delete": "删除",
|
||||
"habitsTitle": "辅助习惯",
|
||||
"habitsSubtitle": "可选的重复动作,今日重点仍然放在前面。",
|
||||
"emptyHabitsTitle": "添加一个辅助习惯",
|
||||
"addHabitTitle": "添加习惯",
|
||||
"renameHabitTitle": "重命名习惯",
|
||||
"habitHint": "例如:读 10 页书",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"textEntryRequired": "先写一个短句。",
|
||||
"setIntentionTitle": "设置今日重点",
|
||||
"editIntentionTitle": "编辑今日重点",
|
||||
"intentionHint": "今天最重要的是什么?",
|
||||
"deleteHabitTitle": "删除习惯?",
|
||||
"deleteHabitBody": "这个习惯的历史完成记录也会删除。",
|
||||
"close": "关闭",
|
||||
"copy": "复制",
|
||||
"clearTodayDataTitle": "清理今日数据?",
|
||||
"clearTodayDataBody": "只重置今天的重点、打卡、专注记录和复盘,过去日期的数据会保留。",
|
||||
"clearAllDataTitle": "删除全部本地数据?",
|
||||
"clearAllDataBody": "会删除本机的目标、习惯、专注记录和复盘,无法撤销。",
|
||||
"clear": "清除",
|
||||
"deleteAll": "全部删除",
|
||||
"privacyTitle": "隐私与数据",
|
||||
"privacyBodyOne": "Focus Seed 只在本机保存目标、习惯、专注记录、复盘和设置。当前版本没有登录、云同步、广告、分析 SDK 或付费 SDK。",
|
||||
"privacyBodyTwo": "你可以随时在设置中清理今日进度,或删除本机的全部本地数据。",
|
||||
"reflectionEmptyError": "写一句真实的总结。",
|
||||
"reflectionSaveError": "保存失败,请重试。",
|
||||
"reflectionSheetTitle": "留下一句进展",
|
||||
"reflectionSheetSubtitle": "记下刚推进了什么,下次就知道从哪里继续。",
|
||||
"reflectionHint": "刚才推进了什么?",
|
||||
"reflectionMoodLabel": "状态可以稍后补",
|
||||
"savingLabel": "保存中",
|
||||
"saveReflection": "保存记录",
|
||||
"moodCalm": "平静",
|
||||
"moodClear": "清醒",
|
||||
"moodProud": "满意",
|
||||
"moodTired": "疲惫",
|
||||
"meTitle": "我的",
|
||||
"meSubtitle": "管理你的节奏、外观和记录。",
|
||||
"appSettingsTitle": "应用设置",
|
||||
"appearance": "主题",
|
||||
"system": "系统",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"language": "语言",
|
||||
"developmentOnly": "仅开发环境",
|
||||
"languageDefault": "默认",
|
||||
"languageEnglish": "English",
|
||||
"languageChinese": "\u4e2d\u6587",
|
||||
"preferencesTitle": "\u504f\u597d",
|
||||
"preferencesDetail": "\u8ba9\u754c\u9762\u66f4\u987a\u624b",
|
||||
"localData": "\u672c\u5730\u8bb0\u5f55",
|
||||
"localDataDetail": "\u53ea\u5728\u672c\u673a",
|
||||
"personalGardenTitle": "\u4f60\u7684\u4e13\u6ce8\u82b1\u56ed",
|
||||
"personalGardenSubtitle": "\u6bcf\u4e00\u6b21\u5c0f\u4e13\u6ce8\uff0c\u90fd\u4f1a\u7559\u4e0b\u53ef\u56de\u770b\u7684\u75d5\u8ff9\u3002",
|
||||
"focusDataLabel": "\u4e13\u6ce8",
|
||||
"habitsDataLabel": "\u4e60\u60ef",
|
||||
"notesDataLabel": "\u8bb0\u5f55",
|
||||
"clearToday": "\u6e05\u7406\u4eca\u5929",
|
||||
"clearTodaySubtitle": "\u53ea\u91cd\u7f6e\u4eca\u5929\u7684\u95ed\u73af\u3002",
|
||||
"privacySubtitle": "\u65e0\u8d26\u53f7\uff0c\u65e0\u4e91\u540c\u6b65\u3002",
|
||||
"deleteAllLocalData": "\u5220\u9664\u5168\u90e8\u6570\u636e",
|
||||
"deleteAllLocalDataSubtitle": "\u79fb\u9664\u8fd9\u53f0\u8bbe\u5907\u4e0a\u7684 Focus Seed \u8bb0\u5f55\u3002",
|
||||
"todayDataCleared": "\u4eca\u65e5\u6570\u636e\u5df2\u6e05\u7406",
|
||||
"localDataCleared": "\u672c\u5730\u6570\u636e\u5df2\u5220\u9664",
|
||||
"journeyTitle": "\u8fdb\u5c55",
|
||||
"journeyOverviewLabel": "\u4eca\u65e5\u95ed\u73af",
|
||||
"journeyOverviewStartTitle": "\u5148\u5b9a\u4e0b\u4e00\u4e2a\u91cd\u70b9",
|
||||
"journeyOverviewStartBody": "\u5199\u4e0b\u4eca\u5929\u6700\u503c\u5f97\u5b8c\u6210\u7684\u90a3\u4ef6\u4e8b\u3002",
|
||||
"journeyOverviewActiveTitle": "\u4eca\u5929\u6b63\u5728\u6210\u5f62",
|
||||
"journeyOverviewActiveBody": "\u7ee7\u7eed\u63a8\u8fdb\uff1a\u4e13\u6ce8\u3001\u6253\u5361\uff0c\u7136\u540e\u7528\u4e00\u53e5\u8bdd\u590d\u76d8\u3002",
|
||||
"journeyOverviewDoneTitle": "\u4eca\u65e5\u95ed\u73af\u5b8c\u6210",
|
||||
"journeyOverviewDoneBody": "\u4f60\u5df2\u7ecf\u7ed9\u672a\u6765\u7684\u81ea\u5df1\u7559\u4e0b\u4e86\u4e00\u6761\u6e05\u6670\u8bb0\u5f55\u3002",
|
||||
"journeyOverviewProgress": "\u5df2\u5b8c\u6210 {completed}/4",
|
||||
"@journeyOverviewProgress": {
|
||||
"languageChinese": "中文",
|
||||
"preferencesTitle": "偏好",
|
||||
"preferencesDetail": "主题、语言和默认专注",
|
||||
"defaultFocusDuration": "默认专注",
|
||||
"defaultFocusDurationDetail": "下一轮",
|
||||
"localData": "本地记录",
|
||||
"localDataDetail": "只在本机",
|
||||
"personalGardenTitle": "我的累计",
|
||||
"personalGardenSubtitle": "你的本地行动记录、进展和应用控制都保留在这台设备上。",
|
||||
"focusDataLabel": "专注",
|
||||
"habitsDataLabel": "习惯",
|
||||
"notesDataLabel": "进展",
|
||||
"totalActionTimeLabel": "行动时间",
|
||||
"recordsCountLabel": "记录",
|
||||
"progressNotesCountLabel": "进展",
|
||||
"clearToday": "清理今天",
|
||||
"clearTodaySubtitle": "只重置今天的闭环。",
|
||||
"privacySubtitle": "无账号,无云同步。",
|
||||
"deleteAllLocalData": "删除全部数据",
|
||||
"deleteAllLocalDataSubtitle": "移除这台设备上的 Focus Seed 记录。",
|
||||
"todayDataCleared": "今日数据已清理",
|
||||
"localDataCleared": "本地数据已删除",
|
||||
"todayLoopLabel": "今日闭环",
|
||||
"todayLoopStartTitle": "先定下一个重点",
|
||||
"todayLoopStartBody": "先写下今天最重要的一件事。",
|
||||
"todayLoopActiveTitle": "今天正在成形",
|
||||
"todayLoopActiveBody": "先完成一轮专注,再留一句有用的记录。",
|
||||
"todayLoopDoneTitle": "进展已保存",
|
||||
"todayLoopDoneBody": "今天已经推进了一点,下次也知道从哪里继续。",
|
||||
"todayLoopProgress": "已完成 {completed}/3",
|
||||
"@todayLoopProgress": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewNow": "\u73b0\u5728\u590d\u76d8",
|
||||
"journeyStatsTitle": "7 \u65e5\u8282\u594f",
|
||||
"weekFocusLabel": "\u4e13\u6ce8",
|
||||
"streakLabel": "\u8fde\u7eed",
|
||||
"activeDaysLabel": "\u6d3b\u8dc3",
|
||||
"habitRateLabel": "\u4e60\u60ef",
|
||||
"daysValue": "{count}\u5929",
|
||||
"reviewNow": "写一句记录",
|
||||
"weekFocusLabel": "专注",
|
||||
"streakLabel": "连续",
|
||||
"activeDaysLabel": "活跃",
|
||||
"habitRateLabel": "习惯",
|
||||
"daysValue": "{count}天",
|
||||
"@daysValue": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -201,17 +220,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewHistory": "\u8bb0\u5f55\u5e93",
|
||||
"viewHistorySubtitle": "\u67e5\u770b\u4e13\u6ce8\u8bb0\u5f55\u548c\u590d\u76d8\u3002",
|
||||
"historyTitle": "\u5386\u53f2",
|
||||
"focusHistory": "\u4e13\u6ce8",
|
||||
"notesHistory": "\u590d\u76d8",
|
||||
"emptyFocusHistoryTitle": "\u8fd8\u6ca1\u6709\u4e13\u6ce8\u8bb0\u5f55",
|
||||
"emptyFocusHistoryBody": "\u4ece\u4eca\u65e5\u9875\u5f00\u59cb\u4e00\u8f6e\u4e13\u6ce8\uff0c\u5b8c\u6210\u540e\u4f1a\u51fa\u73b0\u5728\u8fd9\u91cc\u3002",
|
||||
"emptyNotesHistoryTitle": "\u8fd8\u6ca1\u6709\u590d\u76d8",
|
||||
"emptyNotesHistoryBody": "\u4e13\u6ce8\u540e\u4fdd\u5b58\u4e00\u53e5\u590d\u76d8\uff0c\u8ba9\u8fdb\u5c55\u66f4\u6e05\u6670\u3002",
|
||||
"recentFocus": "\u6700\u8fd1\u4e13\u6ce8",
|
||||
"sessionsTotal": "\u5171 {count} \u6b21",
|
||||
"viewHistory": "记录库",
|
||||
"viewHistorySubtitle": "查看专注记录和复盘。",
|
||||
"historyTitle": "历史",
|
||||
"focusHistory": "专注",
|
||||
"notesHistory": "复盘",
|
||||
"emptyFocusHistoryTitle": "还没有专注记录",
|
||||
"emptyFocusHistoryBody": "完成一轮专注后,记录会出现在这里。",
|
||||
"emptyNotesHistoryTitle": "还没有复盘",
|
||||
"emptyNotesHistoryBody": "专注后留一句复盘,这里会保留它。",
|
||||
"recordsEmptyTitle": "还没有进展记录",
|
||||
"recordsEmptyBody": "完成一次专注,再留一句记录,这里就会保留今天的进展。",
|
||||
"recordsCalendarTitle": "活动日期",
|
||||
"recordsDateLedgerTitle": "当日账本",
|
||||
"backToToday": "回到今日",
|
||||
"recordsNoIntention": "未命名今日重点",
|
||||
"recordsNoReflection": "还没有进展记录",
|
||||
"recordsMissingProgressHelper": "补一句,之后更容易接上。",
|
||||
"addProgressNote": "补一句进展",
|
||||
"continueThisAction": "继续这件事",
|
||||
"replaceTodayActionTitle": "替换今天的行动?",
|
||||
"replaceTodayActionBody": "会把今天的行动改为“{actionTitle}”,已有记录不会删除。",
|
||||
"@replaceTodayActionBody": {
|
||||
"placeholders": {
|
||||
"actionTitle": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recentFocus": "最近专注",
|
||||
"sessionsTotal": "共 {count} 次",
|
||||
"@sessionsTotal": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -219,7 +257,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"focusMinutes": "\u4e13\u6ce8 {minutes} \u5206\u949f",
|
||||
"focusMinutes": "专注 {minutes} 分钟",
|
||||
"@focusMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
@@ -227,9 +265,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"recentReflections": "\u6700\u8fd1\u590d\u76d8",
|
||||
"recentReflectionsDetail": "\u503c\u5f97\u4fdd\u7559\u7684\u8bb0\u5f55",
|
||||
"focusedMinutes": "\u5df2\u4e13\u6ce8 {minutes} \u5206\u949f",
|
||||
"recentReflections": "最近复盘",
|
||||
"recentReflectionsDetail": "值得保留的记录",
|
||||
"focusedMinutes": "已专注 {minutes} 分钟",
|
||||
"@focusedMinutes": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
@@ -237,8 +275,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"noFocusSessionYet": "\u8fd8\u6ca1\u6709\u5b8c\u6210\u4e13\u6ce8",
|
||||
"habitsCompleted": "{completed}/{total} \u5b8c\u6210",
|
||||
"noFocusSessionYet": "还没有完成专注",
|
||||
"habitsCompleted": "{completed}/{total} 完成",
|
||||
"@habitsCompleted": {
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
@@ -249,33 +287,63 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"reflectionCaptured": "\u4eca\u65e5\u590d\u76d8\u5df2\u8bb0\u5f55",
|
||||
"reflectionAction": "\u7528\u4e00\u53e5\u8bdd\u6536\u5c3e",
|
||||
"growthTitle": "\u6210\u957f",
|
||||
"growthFlowerOpen": "\u4eca\u65e5\u82b1\u6735\u5df2\u5f00\u653e",
|
||||
"growthLocked": "\u590d\u76d8\u540e\u89e3\u9501",
|
||||
"sevenDayGrowth": "7 \u65e5\u6210\u957f",
|
||||
"sevenDayGrowthSubtitle": "\u8fde\u7eed\u884c\u52a8\u4f1a\u88ab\u770b\u89c1\u3002",
|
||||
"growthSeed": "\u79cd\u5b50",
|
||||
"growthSprout": "\u53d1\u82bd",
|
||||
"growthLeaves": "\u751f\u957f",
|
||||
"growthFlower": "\u5f00\u82b1",
|
||||
"growthDaySemantic": "{date}\uff0c{stage}",
|
||||
"@growthDaySemantic": {
|
||||
"reflectionCaptured": "今日复盘已记录",
|
||||
"reflectionAction": "用一句话给今天收尾",
|
||||
"nextActionTitle": "下一步",
|
||||
"nextActionSetFocusTitle": "写下今天的重点",
|
||||
"nextActionSetFocusBody": "一个短句就够了,之后还可以调整。",
|
||||
"nextActionStartFocusTitle": "开始 10 分钟",
|
||||
"nextActionStartFocusBody": "写不写都可以,先开始。",
|
||||
"nextActionStartActionBody": "先推进一小段,完成后留一句进展。",
|
||||
"nextActionHabitsTitle": "完成一个小习惯",
|
||||
"nextActionHabitsBody": "趁着还顺手,先做掉一个可重复的小动作。",
|
||||
"nextActionReflectTitle": "写一句记录",
|
||||
"nextActionReflectBody": "记下刚才推进了什么,下次更容易继续。",
|
||||
"nextActionCompleteTitle": "今天已经推进了一点",
|
||||
"nextActionCompleteBody": "进展已经留下,想继续时再来一段。",
|
||||
"nextActionContinueFocus": "再专注 {minutes} 分钟",
|
||||
"@nextActionContinueFocus": {
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "Object"
|
||||
},
|
||||
"stage": {
|
||||
"type": "Object"
|
||||
"minutes": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"growthSeedSemantic": "\u7b49\u5f85\u4eca\u65e5\u76ee\u6807\u7684\u79cd\u5b50",
|
||||
"growthSproutSemantic": "\u76ee\u6807\u5df2\u79cd\u4e0b\uff0c\u6b63\u5728\u53d1\u82bd",
|
||||
"growthLeavesSemantic": "\u4eca\u65e5\u8fdb\u5ea6\u8ba9\u690d\u7269\u751f\u957f",
|
||||
"growthFlowerSemantic": "\u4eca\u65e5\u95ed\u73af\u5b8c\u6210\uff0c\u82b1\u6735\u5f00\u653e",
|
||||
"defaultHabitPlanTask": "\u8ba1\u5212\u4e00\u4e2a\u660e\u786e\u4efb\u52a1",
|
||||
"defaultHabitFocus": "\u4e13\u6ce8 25 \u5206\u949f",
|
||||
"defaultHabitReflect": "\u5199\u4e00\u53e5\u590d\u76d8"
|
||||
"habitTemplatesTitle": "可以直接开始",
|
||||
"habitTemplatesBody": "添加一个很小但能重复的动作。",
|
||||
"habitTemplateStretch": "2 分钟拉伸",
|
||||
"habitTemplateRead": "读 10 页书",
|
||||
"habitTemplateWalk": "出门走走",
|
||||
"habitTemplateNoScroll": "午餐不刷手机",
|
||||
"languageDetail": "界面语言",
|
||||
"recordsAndPrivacyTitle": "记录与隐私",
|
||||
"recordsAndPrivacyBody": "历史只保存在本机。你可以保留、重置或彻底删除。",
|
||||
"welcomeTrustLabel": "今日",
|
||||
"welcomeTitle": "每天只种一件事。",
|
||||
"welcomeBody": "先选今天最重要的一件。",
|
||||
"welcomeNext": "下一步会从这里开始。",
|
||||
"onboardingFocusBadge": "专注",
|
||||
"onboardingFocusTitle": "先守住 10 分钟。",
|
||||
"onboardingFocusBody": "时间不用长,能开始就有进展。",
|
||||
"onboardingFocusNext": "专注时,把这件事留在眼前。",
|
||||
"onboardingReflectBadge": "记录",
|
||||
"onboardingReflectTitle": "留下一句进展。",
|
||||
"onboardingReflectBody": "结束时记一句,下次就知道从哪里继续。",
|
||||
"onboardingReflectNext": "现在设置今天重点。",
|
||||
"onboardingNext": "继续",
|
||||
"welcomeStart": "设置今天重点",
|
||||
"welcomeSkip": "跳过",
|
||||
"landingTitle": "先开始 10 分钟。",
|
||||
"landingBody": "写不写都可以,先开始。",
|
||||
"landingFocusLabel": "今天想先做什么?",
|
||||
"landingFocusEntry": "写下这次要做的事",
|
||||
"landingFocusHint": "例如:完成上线检查清单",
|
||||
"customFocusDuration": "自定义时长",
|
||||
"customFocusDurationHint": "输入 1-180 分钟",
|
||||
"minutesInputLabel": "分钟数",
|
||||
"landingStart": "开始 10 分钟",
|
||||
"landingSkip": "进入今日页",
|
||||
"defaultHabitPlanTask": "计划一个明确任务",
|
||||
"defaultHabitFocus": "专注 10 分钟",
|
||||
"defaultHabitReflect": "写一句复盘"
|
||||
}
|
||||
|
||||
@@ -125,11 +125,11 @@ abstract class AppLocalizations {
|
||||
/// **'Today'**
|
||||
String get navToday;
|
||||
|
||||
/// No description provided for @navJourney.
|
||||
/// No description provided for @navRecords.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Journey'**
|
||||
String get navJourney;
|
||||
/// **'Records'**
|
||||
String get navRecords;
|
||||
|
||||
/// No description provided for @navMe.
|
||||
///
|
||||
@@ -149,10 +149,16 @@ abstract class AppLocalizations {
|
||||
/// **'Could not open Focus Seed.'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your local garden could not be opened.'**
|
||||
/// **'Your local data could not be opened.'**
|
||||
String get openFailed;
|
||||
|
||||
/// No description provided for @saveFailed.
|
||||
@@ -233,12 +239,30 @@ abstract class AppLocalizations {
|
||||
/// **'Session complete'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reflect for 1 min'**
|
||||
/// **'Write progress note'**
|
||||
String get reflectOneMinute;
|
||||
|
||||
/// No description provided for @startFocus.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start focus'**
|
||||
String get startFocus;
|
||||
|
||||
/// No description provided for @startAnotherSession.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -263,6 +287,18 @@ abstract class AppLocalizations {
|
||||
/// **'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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -314,7 +350,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @emptyHabitPrompt.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add one repeatable habit.'**
|
||||
/// **'Keep this optional and small.'**
|
||||
String get emptyHabitPrompt;
|
||||
|
||||
/// No description provided for @viewAllHabits.
|
||||
@@ -392,7 +428,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @dailyStepsComplete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{completed}/4 daily steps complete'**
|
||||
/// **'{completed}/3 daily steps complete'**
|
||||
String dailyStepsComplete(int completed);
|
||||
|
||||
/// No description provided for @habitOptions.
|
||||
@@ -416,19 +452,19 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @habitsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Habits'**
|
||||
/// **'Supporting habits'**
|
||||
String get habitsTitle;
|
||||
|
||||
/// No description provided for @habitsSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep it small and repeatable.'**
|
||||
/// **'Optional repeatable steps. Today\'s focus still comes first.'**
|
||||
String get habitsSubtitle;
|
||||
|
||||
/// No description provided for @emptyHabitsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add a small habit'**
|
||||
/// **'Add a supporting habit'**
|
||||
String get emptyHabitsTitle;
|
||||
|
||||
/// No description provided for @addHabitTitle.
|
||||
@@ -461,16 +497,22 @@ abstract class AppLocalizations {
|
||||
/// **'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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Set intention'**
|
||||
/// **'Set today\'s focus'**
|
||||
String get setIntentionTitle;
|
||||
|
||||
/// No description provided for @editIntentionTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit intention'**
|
||||
/// **'Edit today\'s focus'**
|
||||
String get editIntentionTitle;
|
||||
|
||||
/// No description provided for @intentionHint.
|
||||
@@ -554,7 +596,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @privacyBodyTwo.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
/// No description provided for @reflectionEmptyError.
|
||||
@@ -572,21 +614,27 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @reflectionSheetTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How did it go?'**
|
||||
/// **'Leave one progress note'**
|
||||
String get reflectionSheetTitle;
|
||||
|
||||
/// No description provided for @reflectionSheetSubtitle.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
/// No description provided for @reflectionHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'One useful sentence is enough.'**
|
||||
/// **'What moved forward?'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -596,7 +644,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @saveReflection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save reflection'**
|
||||
/// **'Save note'**
|
||||
String get saveReflection;
|
||||
|
||||
/// No description provided for @moodCalm.
|
||||
@@ -626,15 +674,21 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @meTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your garden'**
|
||||
/// **'Me'**
|
||||
String get meTitle;
|
||||
|
||||
/// No description provided for @meSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Preferences and records stay on this device.'**
|
||||
/// **'Manage your rhythm, appearance, and records.'**
|
||||
String get meSubtitle;
|
||||
|
||||
/// No description provided for @appSettingsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'App settings'**
|
||||
String get appSettingsTitle;
|
||||
|
||||
/// No description provided for @appearance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -698,9 +752,21 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @preferencesDetail.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Make it feel right'**
|
||||
/// **'Theme, language, and default focus'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -716,13 +782,13 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @personalGardenTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your focus garden'**
|
||||
/// **'My accumulation'**
|
||||
String get personalGardenTitle;
|
||||
|
||||
/// No description provided for @personalGardenSubtitle.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
/// No description provided for @focusDataLabel.
|
||||
@@ -740,9 +806,27 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @notesDataLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Notes'**
|
||||
/// **'Progress'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -785,72 +869,60 @@ abstract class AppLocalizations {
|
||||
/// **'Local data deleted'**
|
||||
String get localDataCleared;
|
||||
|
||||
/// No description provided for @journeyTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Progress'**
|
||||
String get journeyTitle;
|
||||
|
||||
/// No description provided for @journeyOverviewLabel.
|
||||
/// No description provided for @todayLoopLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'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:
|
||||
/// **'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:
|
||||
/// **'Pick the thing that would make today feel finished.'**
|
||||
String get journeyOverviewStartBody;
|
||||
/// **'Pick the one result that matters today.'**
|
||||
String get todayLoopStartBody;
|
||||
|
||||
/// No description provided for @journeyOverviewActiveTitle.
|
||||
/// No description provided for @todayLoopActiveTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'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:
|
||||
/// **'Keep the loop moving: focus, habits, then a short reflection.'**
|
||||
String get journeyOverviewActiveBody;
|
||||
/// **'Focus once, then leave one useful note.'**
|
||||
String get todayLoopActiveBody;
|
||||
|
||||
/// No description provided for @journeyOverviewDoneTitle.
|
||||
/// No description provided for @todayLoopDoneTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Loop complete'**
|
||||
String get journeyOverviewDoneTitle;
|
||||
/// **'Progress saved'**
|
||||
String get todayLoopDoneTitle;
|
||||
|
||||
/// No description provided for @journeyOverviewDoneBody.
|
||||
/// No description provided for @todayLoopDoneBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You left a clean record for future you.'**
|
||||
String get journeyOverviewDoneBody;
|
||||
/// **'You moved today forward and left a clear place to continue.'**
|
||||
String get todayLoopDoneBody;
|
||||
|
||||
/// No description provided for @journeyOverviewProgress.
|
||||
/// No description provided for @todayLoopProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{completed}/4 done'**
|
||||
String journeyOverviewProgress(int completed);
|
||||
/// **'{completed}/3 done'**
|
||||
String todayLoopProgress(int completed);
|
||||
|
||||
/// No description provided for @reviewNow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reflect now'**
|
||||
/// **'Write a note'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -926,7 +998,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @emptyFocusHistoryBody.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
/// No description provided for @emptyNotesHistoryTitle.
|
||||
@@ -938,9 +1010,81 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @emptyNotesHistoryBody.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -998,92 +1142,290 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @reflectionAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Close the loop with one sentence'**
|
||||
/// **'Write one sentence to close today'**
|
||||
String get reflectionAction;
|
||||
|
||||
/// No description provided for @growthTitle.
|
||||
/// No description provided for @nextActionTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Growth'**
|
||||
String get growthTitle;
|
||||
/// **'Next action'**
|
||||
String get nextActionTitle;
|
||||
|
||||
/// No description provided for @growthFlowerOpen.
|
||||
/// No description provided for @nextActionSetFocusTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Today\'s flower is open'**
|
||||
String get growthFlowerOpen;
|
||||
/// **'Write today\'s focus'**
|
||||
String get nextActionSetFocusTitle;
|
||||
|
||||
/// No description provided for @growthLocked.
|
||||
/// No description provided for @nextActionSetFocusBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unlocked after reflection'**
|
||||
String get growthLocked;
|
||||
/// **'One short line is enough. You can adjust it later.'**
|
||||
String get nextActionSetFocusBody;
|
||||
|
||||
/// No description provided for @sevenDayGrowth.
|
||||
/// No description provided for @nextActionStartFocusTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'7-day growth'**
|
||||
String get sevenDayGrowth;
|
||||
/// **'Start 10 minutes'**
|
||||
String get nextActionStartFocusTitle;
|
||||
|
||||
/// No description provided for @sevenDayGrowthSubtitle.
|
||||
/// No description provided for @nextActionStartFocusBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Small actions become visible over time.'**
|
||||
String get sevenDayGrowthSubtitle;
|
||||
/// **'Writing the action is optional. Start first.'**
|
||||
String get nextActionStartFocusBody;
|
||||
|
||||
/// No description provided for @growthSeed.
|
||||
/// No description provided for @nextActionStartActionBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'seed'**
|
||||
String get growthSeed;
|
||||
/// **'Move this forward in one small session, then leave one progress note.'**
|
||||
String get nextActionStartActionBody;
|
||||
|
||||
/// No description provided for @growthSprout.
|
||||
/// No description provided for @nextActionHabitsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'sprout'**
|
||||
String get growthSprout;
|
||||
/// **'Finish one small habit'**
|
||||
String get nextActionHabitsTitle;
|
||||
|
||||
/// No description provided for @growthLeaves.
|
||||
/// No description provided for @nextActionHabitsBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'leaves'**
|
||||
String get growthLeaves;
|
||||
/// **'Complete one repeatable step while it is still easy.'**
|
||||
String get nextActionHabitsBody;
|
||||
|
||||
/// No description provided for @growthFlower.
|
||||
/// No description provided for @nextActionReflectTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'flower'**
|
||||
String get growthFlower;
|
||||
/// **'Write a quick note'**
|
||||
String get nextActionReflectTitle;
|
||||
|
||||
/// No description provided for @growthDaySemantic.
|
||||
/// No description provided for @nextActionReflectBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{date}, {stage}'**
|
||||
String growthDaySemantic(Object date, Object stage);
|
||||
/// **'Record what moved forward so the next start is easier.'**
|
||||
String get nextActionReflectBody;
|
||||
|
||||
/// No description provided for @growthSeedSemantic.
|
||||
/// No description provided for @nextActionCompleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A seed waiting for today\'s intention'**
|
||||
String get growthSeedSemantic;
|
||||
/// **'Today moved forward'**
|
||||
String get nextActionCompleteTitle;
|
||||
|
||||
/// No description provided for @growthSproutSemantic.
|
||||
/// No description provided for @nextActionCompleteBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A sprout growing from today\'s intention'**
|
||||
String get growthSproutSemantic;
|
||||
/// **'Your note is saved. Continue only if it still feels useful.'**
|
||||
String get nextActionCompleteBody;
|
||||
|
||||
/// No description provided for @growthLeavesSemantic.
|
||||
/// No description provided for @nextActionContinueFocus.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A young plant growing with today\'s progress'**
|
||||
String get growthLeavesSemantic;
|
||||
/// **'Focus {minutes} min again'**
|
||||
String nextActionContinueFocus(int minutes);
|
||||
|
||||
/// No description provided for @growthFlowerSemantic.
|
||||
/// No description provided for @habitTemplatesTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A flower marking a complete daily cycle'**
|
||||
String get growthFlowerSemantic;
|
||||
/// **'Useful starts'**
|
||||
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.
|
||||
///
|
||||
@@ -1094,7 +1436,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @defaultHabitFocus.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Focus for 25 minutes'**
|
||||
/// **'Focus for 10 minutes'**
|
||||
String get defaultHabitFocus;
|
||||
|
||||
/// No description provided for @defaultHabitReflect.
|
||||
|
||||
@@ -24,7 +24,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get navToday => 'Today';
|
||||
|
||||
@override
|
||||
String get navJourney => 'Journey';
|
||||
String get navRecords => 'Records';
|
||||
|
||||
@override
|
||||
String get navMe => 'Me';
|
||||
@@ -36,7 +36,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get couldNotOpenApp => 'Could not open Focus Seed.';
|
||||
|
||||
@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
|
||||
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';
|
||||
|
||||
@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
|
||||
String get startAnotherSession => 'Start another session';
|
||||
@@ -94,6 +108,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get end => 'End';
|
||||
|
||||
@override
|
||||
String get completeSession => 'Complete session';
|
||||
|
||||
@override
|
||||
String get continueSession => 'Continue session';
|
||||
|
||||
@override
|
||||
String get resume => 'Resume';
|
||||
|
||||
@@ -123,7 +143,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get add => 'Add';
|
||||
|
||||
@override
|
||||
String get emptyHabitPrompt => 'Add one repeatable habit.';
|
||||
String get emptyHabitPrompt => 'Keep this optional and small.';
|
||||
|
||||
@override
|
||||
String viewAllHabits(int count) {
|
||||
@@ -167,7 +187,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String dailyStepsComplete(int completed) {
|
||||
return '$completed/4 daily steps complete';
|
||||
return '$completed/3 daily steps complete';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -180,13 +200,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get delete => 'Delete';
|
||||
|
||||
@override
|
||||
String get habitsTitle => 'Habits';
|
||||
String get habitsTitle => 'Supporting habits';
|
||||
|
||||
@override
|
||||
String get habitsSubtitle => 'Keep it small and repeatable.';
|
||||
String get habitsSubtitle => 'Optional repeatable steps. Today\'s focus still comes first.';
|
||||
|
||||
@override
|
||||
String get emptyHabitsTitle => 'Add a small habit';
|
||||
String get emptyHabitsTitle => 'Add a supporting habit';
|
||||
|
||||
@override
|
||||
String get addHabitTitle => 'Add habit';
|
||||
@@ -204,10 +224,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get save => 'Save';
|
||||
|
||||
@override
|
||||
String get setIntentionTitle => 'Set intention';
|
||||
String get textEntryRequired => 'Write a short line first.';
|
||||
|
||||
@override
|
||||
String get editIntentionTitle => 'Edit intention';
|
||||
String get setIntentionTitle => 'Set today\'s focus';
|
||||
|
||||
@override
|
||||
String get editIntentionTitle => 'Edit today\'s focus';
|
||||
|
||||
@override
|
||||
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.';
|
||||
|
||||
@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
|
||||
String get reflectionEmptyError => 'Write one honest sentence.';
|
||||
@@ -258,19 +281,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get reflectionSaveError => 'Could not save. Please try again.';
|
||||
|
||||
@override
|
||||
String get reflectionSheetTitle => 'How did it go?';
|
||||
String get reflectionSheetTitle => 'Leave one progress note';
|
||||
|
||||
@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
|
||||
String get reflectionHint => 'One useful sentence is enough.';
|
||||
String get reflectionHint => 'What moved forward?';
|
||||
|
||||
@override
|
||||
String get reflectionMoodLabel => 'Mood is optional';
|
||||
|
||||
@override
|
||||
String get savingLabel => 'Saving';
|
||||
|
||||
@override
|
||||
String get saveReflection => 'Save reflection';
|
||||
String get saveReflection => 'Save note';
|
||||
|
||||
@override
|
||||
String get moodCalm => 'Calm';
|
||||
@@ -285,10 +311,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get moodTired => 'Tired';
|
||||
|
||||
@override
|
||||
String get meTitle => 'Your garden';
|
||||
String get meTitle => 'Me';
|
||||
|
||||
@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
|
||||
String get appearance => 'Theme';
|
||||
@@ -321,7 +350,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get preferencesTitle => 'Preferences';
|
||||
|
||||
@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
|
||||
String get localData => 'Local records';
|
||||
@@ -330,10 +365,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get localDataDetail => 'Private on device';
|
||||
|
||||
@override
|
||||
String get personalGardenTitle => 'Your focus garden';
|
||||
String get personalGardenTitle => 'My accumulation';
|
||||
|
||||
@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
|
||||
String get focusDataLabel => 'Focus';
|
||||
@@ -342,7 +377,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get habitsDataLabel => 'Habits';
|
||||
|
||||
@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
|
||||
String get clearToday => 'Clear today';
|
||||
@@ -366,39 +410,33 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get localDataCleared => 'Local data deleted';
|
||||
|
||||
@override
|
||||
String get journeyTitle => 'Progress';
|
||||
String get todayLoopLabel => 'TODAY\'S LOOP';
|
||||
|
||||
@override
|
||||
String get journeyOverviewLabel => 'TODAY\'S LOOP';
|
||||
String get todayLoopStartTitle => 'Start with one clear focus';
|
||||
|
||||
@override
|
||||
String get journeyOverviewStartTitle => 'Start with one clear focus';
|
||||
String get todayLoopStartBody => 'Pick the one result that matters today.';
|
||||
|
||||
@override
|
||||
String get journeyOverviewStartBody => 'Pick the thing that would make today feel finished.';
|
||||
String get todayLoopActiveTitle => 'Your day is taking shape';
|
||||
|
||||
@override
|
||||
String get journeyOverviewActiveTitle => 'Your day is taking shape';
|
||||
String get todayLoopActiveBody => 'Focus once, then leave one useful note.';
|
||||
|
||||
@override
|
||||
String get journeyOverviewActiveBody => 'Keep the loop moving: focus, habits, then a short reflection.';
|
||||
String get todayLoopDoneTitle => 'Progress saved';
|
||||
|
||||
@override
|
||||
String get journeyOverviewDoneTitle => 'Loop complete';
|
||||
String get todayLoopDoneBody => 'You moved today forward and left a clear place to continue.';
|
||||
|
||||
@override
|
||||
String get journeyOverviewDoneBody => 'You left a clean record for future you.';
|
||||
|
||||
@override
|
||||
String journeyOverviewProgress(int completed) {
|
||||
return '$completed/4 done';
|
||||
String todayLoopProgress(int completed) {
|
||||
return '$completed/3 done';
|
||||
}
|
||||
|
||||
@override
|
||||
String get reviewNow => 'Reflect now';
|
||||
|
||||
@override
|
||||
String get journeyStatsTitle => '7-day rhythm';
|
||||
String get reviewNow => 'Write a note';
|
||||
|
||||
@override
|
||||
String get weekFocusLabel => 'Focus';
|
||||
@@ -441,13 +479,51 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get emptyFocusHistoryTitle => 'No focus sessions yet';
|
||||
|
||||
@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
|
||||
String get emptyNotesHistoryTitle => 'No notes yet';
|
||||
|
||||
@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
|
||||
String get recentFocus => 'Recent focus';
|
||||
@@ -485,57 +561,156 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get reflectionCaptured => 'Today\'s lesson is captured';
|
||||
|
||||
@override
|
||||
String get reflectionAction => 'Close the loop with one sentence';
|
||||
String get reflectionAction => 'Write one sentence to close today';
|
||||
|
||||
@override
|
||||
String get growthTitle => 'Growth';
|
||||
String get nextActionTitle => 'Next action';
|
||||
|
||||
@override
|
||||
String get growthFlowerOpen => 'Today\'s flower is open';
|
||||
String get nextActionSetFocusTitle => 'Write today\'s focus';
|
||||
|
||||
@override
|
||||
String get growthLocked => 'Unlocked after reflection';
|
||||
String get nextActionSetFocusBody => 'One short line is enough. You can adjust it later.';
|
||||
|
||||
@override
|
||||
String get sevenDayGrowth => '7-day growth';
|
||||
String get nextActionStartFocusTitle => 'Start 10 minutes';
|
||||
|
||||
@override
|
||||
String get sevenDayGrowthSubtitle => 'Small actions become visible over time.';
|
||||
String get nextActionStartFocusBody => 'Writing the action is optional. Start first.';
|
||||
|
||||
@override
|
||||
String get growthSeed => 'seed';
|
||||
String get nextActionStartActionBody => 'Move this forward in one small session, then leave one progress note.';
|
||||
|
||||
@override
|
||||
String get growthSprout => 'sprout';
|
||||
String get nextActionHabitsTitle => 'Finish one small habit';
|
||||
|
||||
@override
|
||||
String get growthLeaves => 'leaves';
|
||||
String get nextActionHabitsBody => 'Complete one repeatable step while it is still easy.';
|
||||
|
||||
@override
|
||||
String get growthFlower => 'flower';
|
||||
String get nextActionReflectTitle => 'Write a quick note';
|
||||
|
||||
@override
|
||||
String growthDaySemantic(Object date, Object stage) {
|
||||
return '$date, $stage';
|
||||
String get nextActionReflectBody => 'Record what moved forward so the next start is easier.';
|
||||
|
||||
@override
|
||||
String get nextActionCompleteTitle => 'Today moved forward';
|
||||
|
||||
@override
|
||||
String get nextActionCompleteBody => 'Your note is saved. Continue only if it still feels useful.';
|
||||
|
||||
@override
|
||||
String nextActionContinueFocus(int minutes) {
|
||||
return 'Focus $minutes min again';
|
||||
}
|
||||
|
||||
@override
|
||||
String get growthSeedSemantic => 'A seed waiting for today\'s intention';
|
||||
String get habitTemplatesTitle => 'Useful starts';
|
||||
|
||||
@override
|
||||
String get growthSproutSemantic => 'A sprout growing from today\'s intention';
|
||||
String get habitTemplatesBody => 'Add one tiny repeatable action.';
|
||||
|
||||
@override
|
||||
String get growthLeavesSemantic => 'A young plant growing with today\'s progress';
|
||||
String get habitTemplateStretch => '2 min stretch';
|
||||
|
||||
@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
|
||||
String get defaultHabitPlanTask => 'Plan one clear task';
|
||||
|
||||
@override
|
||||
String get defaultHabitFocus => 'Focus for 25 minutes';
|
||||
String get defaultHabitFocus => 'Focus for 10 minutes';
|
||||
|
||||
@override
|
||||
String get defaultHabitReflect => 'Write one-line reflection';
|
||||
|
||||
@@ -24,7 +24,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get navToday => '今日';
|
||||
|
||||
@override
|
||||
String get navJourney => '进展';
|
||||
String get navRecords => '记录';
|
||||
|
||||
@override
|
||||
String get navMe => '我的';
|
||||
@@ -35,6 +35,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get couldNotOpenApp => '无法打开 Focus Seed。';
|
||||
|
||||
@override
|
||||
String get loadingLocalData => '正在读取本地数据';
|
||||
|
||||
@override
|
||||
String get openFailed => '本地数据打开失败。';
|
||||
|
||||
@@ -80,7 +83,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get timerCompleted => '已完成';
|
||||
|
||||
@override
|
||||
String get reflectOneMinute => '复盘 1 分钟';
|
||||
String focusCurrentIntention(String intention) {
|
||||
return '今日重点:$intention';
|
||||
}
|
||||
|
||||
@override
|
||||
String get focusNoIntentionTitle => '先开始这一段';
|
||||
|
||||
@override
|
||||
String get reflectOneMinute => '写一句进展';
|
||||
|
||||
@override
|
||||
String get startFocus => '开始专注';
|
||||
|
||||
@override
|
||||
String get startAnotherSession => '再来一轮';
|
||||
@@ -94,6 +108,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get end => '结束';
|
||||
|
||||
@override
|
||||
String get completeSession => '完成本轮';
|
||||
|
||||
@override
|
||||
String get continueSession => '继续本轮';
|
||||
|
||||
@override
|
||||
String get resume => '继续';
|
||||
|
||||
@@ -123,7 +143,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get add => '添加';
|
||||
|
||||
@override
|
||||
String get emptyHabitPrompt => '添加一个可重复的小习惯。';
|
||||
String get emptyHabitPrompt => '保持可选,也保持很小。';
|
||||
|
||||
@override
|
||||
String viewAllHabits(int count) {
|
||||
@@ -167,7 +187,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String dailyStepsComplete(int completed) {
|
||||
return '今日完成 $completed/4';
|
||||
return '今日完成 $completed/3';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -180,13 +200,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get delete => '删除';
|
||||
|
||||
@override
|
||||
String get habitsTitle => '习惯';
|
||||
String get habitsTitle => '辅助习惯';
|
||||
|
||||
@override
|
||||
String get habitsSubtitle => '少量、可重复。';
|
||||
String get habitsSubtitle => '可选的重复动作,今日重点仍然放在前面。';
|
||||
|
||||
@override
|
||||
String get emptyHabitsTitle => '添加小习惯';
|
||||
String get emptyHabitsTitle => '添加一个辅助习惯';
|
||||
|
||||
@override
|
||||
String get addHabitTitle => '添加习惯';
|
||||
@@ -204,10 +224,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get save => '保存';
|
||||
|
||||
@override
|
||||
String get setIntentionTitle => '设置目标';
|
||||
String get textEntryRequired => '先写一个短句。';
|
||||
|
||||
@override
|
||||
String get editIntentionTitle => '编辑目标';
|
||||
String get setIntentionTitle => '设置今日重点';
|
||||
|
||||
@override
|
||||
String get editIntentionTitle => '编辑今日重点';
|
||||
|
||||
@override
|
||||
String get intentionHint => '今天最重要的是什么?';
|
||||
@@ -249,7 +272,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get privacyBodyOne => 'Focus Seed 只在本机保存目标、习惯、专注记录、复盘和设置。当前版本没有登录、云同步、广告、分析 SDK 或付费 SDK。';
|
||||
|
||||
@override
|
||||
String get privacyBodyTwo => '你可以随时清理今日进度,或删除本机的全部本地数据。';
|
||||
String get privacyBodyTwo => '你可以随时在设置中清理今日进度,或删除本机的全部本地数据。';
|
||||
|
||||
@override
|
||||
String get reflectionEmptyError => '写一句真实的总结。';
|
||||
@@ -258,19 +281,22 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get reflectionSaveError => '保存失败,请重试。';
|
||||
|
||||
@override
|
||||
String get reflectionSheetTitle => '复盘一下';
|
||||
String get reflectionSheetTitle => '留下一句进展';
|
||||
|
||||
@override
|
||||
String get reflectionSheetSubtitle => '保留有效的,调整一个点。';
|
||||
String get reflectionSheetSubtitle => '记下刚推进了什么,下次就知道从哪里继续。';
|
||||
|
||||
@override
|
||||
String get reflectionHint => '一句有用的话就够。';
|
||||
String get reflectionHint => '刚才推进了什么?';
|
||||
|
||||
@override
|
||||
String get reflectionMoodLabel => '状态可以稍后补';
|
||||
|
||||
@override
|
||||
String get savingLabel => '保存中';
|
||||
|
||||
@override
|
||||
String get saveReflection => '保存复盘';
|
||||
String get saveReflection => '保存记录';
|
||||
|
||||
@override
|
||||
String get moodCalm => '平静';
|
||||
@@ -285,10 +311,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get moodTired => '疲惫';
|
||||
|
||||
@override
|
||||
String get meTitle => '我的花园';
|
||||
String get meTitle => '我的';
|
||||
|
||||
@override
|
||||
String get meSubtitle => '偏好和记录都只保存在本机。';
|
||||
String get meSubtitle => '管理你的节奏、外观和记录。';
|
||||
|
||||
@override
|
||||
String get appSettingsTitle => '应用设置';
|
||||
|
||||
@override
|
||||
String get appearance => '主题';
|
||||
@@ -321,7 +350,13 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get preferencesTitle => '偏好';
|
||||
|
||||
@override
|
||||
String get preferencesDetail => '让界面更顺手';
|
||||
String get preferencesDetail => '主题、语言和默认专注';
|
||||
|
||||
@override
|
||||
String get defaultFocusDuration => '默认专注';
|
||||
|
||||
@override
|
||||
String get defaultFocusDurationDetail => '下一轮';
|
||||
|
||||
@override
|
||||
String get localData => '本地记录';
|
||||
@@ -330,10 +365,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get localDataDetail => '只在本机';
|
||||
|
||||
@override
|
||||
String get personalGardenTitle => '你的专注花园';
|
||||
String get personalGardenTitle => '我的累计';
|
||||
|
||||
@override
|
||||
String get personalGardenSubtitle => '每一次小专注,都会留下可回看的痕迹。';
|
||||
String get personalGardenSubtitle => '你的本地行动记录、进展和应用控制都保留在这台设备上。';
|
||||
|
||||
@override
|
||||
String get focusDataLabel => '专注';
|
||||
@@ -342,7 +377,16 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get habitsDataLabel => '习惯';
|
||||
|
||||
@override
|
||||
String get notesDataLabel => '记录';
|
||||
String get notesDataLabel => '进展';
|
||||
|
||||
@override
|
||||
String get totalActionTimeLabel => '行动时间';
|
||||
|
||||
@override
|
||||
String get recordsCountLabel => '记录';
|
||||
|
||||
@override
|
||||
String get progressNotesCountLabel => '进展';
|
||||
|
||||
@override
|
||||
String get clearToday => '清理今天';
|
||||
@@ -366,39 +410,33 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get localDataCleared => '本地数据已删除';
|
||||
|
||||
@override
|
||||
String get journeyTitle => '进展';
|
||||
String get todayLoopLabel => '今日闭环';
|
||||
|
||||
@override
|
||||
String get journeyOverviewLabel => '今日闭环';
|
||||
String get todayLoopStartTitle => '先定下一个重点';
|
||||
|
||||
@override
|
||||
String get journeyOverviewStartTitle => '先定下一个重点';
|
||||
String get todayLoopStartBody => '先写下今天最重要的一件事。';
|
||||
|
||||
@override
|
||||
String get journeyOverviewStartBody => '写下今天最值得完成的那件事。';
|
||||
String get todayLoopActiveTitle => '今天正在成形';
|
||||
|
||||
@override
|
||||
String get journeyOverviewActiveTitle => '今天正在成形';
|
||||
String get todayLoopActiveBody => '先完成一轮专注,再留一句有用的记录。';
|
||||
|
||||
@override
|
||||
String get journeyOverviewActiveBody => '继续推进:专注、打卡,然后用一句话复盘。';
|
||||
String get todayLoopDoneTitle => '进展已保存';
|
||||
|
||||
@override
|
||||
String get journeyOverviewDoneTitle => '今日闭环完成';
|
||||
String get todayLoopDoneBody => '今天已经推进了一点,下次也知道从哪里继续。';
|
||||
|
||||
@override
|
||||
String get journeyOverviewDoneBody => '你已经给未来的自己留下了一条清晰记录。';
|
||||
|
||||
@override
|
||||
String journeyOverviewProgress(int completed) {
|
||||
return '已完成 $completed/4';
|
||||
String todayLoopProgress(int completed) {
|
||||
return '已完成 $completed/3';
|
||||
}
|
||||
|
||||
@override
|
||||
String get reviewNow => '现在复盘';
|
||||
|
||||
@override
|
||||
String get journeyStatsTitle => '7 日节奏';
|
||||
String get reviewNow => '写一句记录';
|
||||
|
||||
@override
|
||||
String get weekFocusLabel => '专注';
|
||||
@@ -441,13 +479,51 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get emptyFocusHistoryTitle => '还没有专注记录';
|
||||
|
||||
@override
|
||||
String get emptyFocusHistoryBody => '从今日页开始一轮专注,完成后会出现在这里。';
|
||||
String get emptyFocusHistoryBody => '完成一轮专注后,记录会出现在这里。';
|
||||
|
||||
@override
|
||||
String get emptyNotesHistoryTitle => '还没有复盘';
|
||||
|
||||
@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
|
||||
String get recentFocus => '最近专注';
|
||||
@@ -485,57 +561,156 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get reflectionCaptured => '今日复盘已记录';
|
||||
|
||||
@override
|
||||
String get reflectionAction => '用一句话收尾';
|
||||
String get reflectionAction => '用一句话给今天收尾';
|
||||
|
||||
@override
|
||||
String get growthTitle => '成长';
|
||||
String get nextActionTitle => '下一步';
|
||||
|
||||
@override
|
||||
String get growthFlowerOpen => '今日花朵已开放';
|
||||
String get nextActionSetFocusTitle => '写下今天的重点';
|
||||
|
||||
@override
|
||||
String get growthLocked => '复盘后解锁';
|
||||
String get nextActionSetFocusBody => '一个短句就够了,之后还可以调整。';
|
||||
|
||||
@override
|
||||
String get sevenDayGrowth => '7 日成长';
|
||||
String get nextActionStartFocusTitle => '开始 10 分钟';
|
||||
|
||||
@override
|
||||
String get sevenDayGrowthSubtitle => '连续行动会被看见。';
|
||||
String get nextActionStartFocusBody => '写不写都可以,先开始。';
|
||||
|
||||
@override
|
||||
String get growthSeed => '种子';
|
||||
String get nextActionStartActionBody => '先推进一小段,完成后留一句进展。';
|
||||
|
||||
@override
|
||||
String get growthSprout => '发芽';
|
||||
String get nextActionHabitsTitle => '完成一个小习惯';
|
||||
|
||||
@override
|
||||
String get growthLeaves => '生长';
|
||||
String get nextActionHabitsBody => '趁着还顺手,先做掉一个可重复的小动作。';
|
||||
|
||||
@override
|
||||
String get growthFlower => '开花';
|
||||
String get nextActionReflectTitle => '写一句记录';
|
||||
|
||||
@override
|
||||
String growthDaySemantic(Object date, Object stage) {
|
||||
return '$date,$stage';
|
||||
String get nextActionReflectBody => '记下刚才推进了什么,下次更容易继续。';
|
||||
|
||||
@override
|
||||
String get nextActionCompleteTitle => '今天已经推进了一点';
|
||||
|
||||
@override
|
||||
String get nextActionCompleteBody => '进展已经留下,想继续时再来一段。';
|
||||
|
||||
@override
|
||||
String nextActionContinueFocus(int minutes) {
|
||||
return '再专注 $minutes 分钟';
|
||||
}
|
||||
|
||||
@override
|
||||
String get growthSeedSemantic => '等待今日目标的种子';
|
||||
String get habitTemplatesTitle => '可以直接开始';
|
||||
|
||||
@override
|
||||
String get growthSproutSemantic => '目标已种下,正在发芽';
|
||||
String get habitTemplatesBody => '添加一个很小但能重复的动作。';
|
||||
|
||||
@override
|
||||
String get growthLeavesSemantic => '今日进度让植物生长';
|
||||
String get habitTemplateStretch => '2 分钟拉伸';
|
||||
|
||||
@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
|
||||
String get defaultHabitPlanTask => '计划一个明确任务';
|
||||
|
||||
@override
|
||||
String get defaultHabitFocus => '专注 25 分钟';
|
||||
String get defaultHabitFocus => '专注 10 分钟';
|
||||
|
||||
@override
|
||||
String get defaultHabitReflect => '写一句复盘';
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../core/ambience.dart';
|
||||
import '../models/growth_stage.dart';
|
||||
import '../models/mood.dart';
|
||||
import 'generated/app_localizations.dart';
|
||||
|
||||
@@ -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) {
|
||||
return [
|
||||
l10n.defaultHabitPlanTask,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import '../core/focus_durations.dart';
|
||||
import 'daily_action.dart';
|
||||
import 'focus_session.dart';
|
||||
import 'habit.dart';
|
||||
import 'progress_note.dart';
|
||||
import 'reflection_entry.dart';
|
||||
|
||||
class AppState {
|
||||
@@ -9,6 +12,11 @@ class AppState {
|
||||
required this.habits,
|
||||
required this.sessions,
|
||||
required this.reflections,
|
||||
this.actions = const [],
|
||||
this.selectedActionId = '',
|
||||
this.progressNotes = const [],
|
||||
this.recentLabels = const [],
|
||||
this.defaultFocusMinutes = defaultFocusDurationMinutes,
|
||||
});
|
||||
|
||||
final String anchor;
|
||||
@@ -16,23 +24,101 @@ class AppState {
|
||||
final List<Habit> habits;
|
||||
final List<FocusSession> sessions;
|
||||
final List<ReflectionEntry> reflections;
|
||||
final List<DailyAction> actions;
|
||||
final String selectedActionId;
|
||||
final List<ProgressNote> progressNotes;
|
||||
final List<String> recentLabels;
|
||||
final int defaultFocusMinutes;
|
||||
|
||||
bool get isFreshStart =>
|
||||
habits.isEmpty &&
|
||||
sessions.isEmpty &&
|
||||
reflections.isEmpty &&
|
||||
anchor.isEmpty;
|
||||
anchor.isEmpty &&
|
||||
actions.isEmpty &&
|
||||
progressNotes.isEmpty;
|
||||
|
||||
String activeAnchor(String dateKey) {
|
||||
return anchorDateKey == dateKey ? anchor : '';
|
||||
}
|
||||
|
||||
List<DailyAction> actionsForDate(
|
||||
String dateKey, {
|
||||
bool includeArchived = false,
|
||||
}) {
|
||||
final filtered = actions
|
||||
.where(
|
||||
(action) =>
|
||||
action.dateKey == dateKey &&
|
||||
(includeArchived || !action.isArchived),
|
||||
)
|
||||
.toList(growable: false);
|
||||
return [...filtered]..sort((a, b) {
|
||||
final byOrder = a.orderIndex.compareTo(b.orderIndex);
|
||||
if (byOrder != 0) {
|
||||
return byOrder;
|
||||
}
|
||||
return a.createdAt.compareTo(b.createdAt);
|
||||
});
|
||||
}
|
||||
|
||||
DailyAction? actionById(String? id) {
|
||||
if (id == null || id.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
for (final action in actions) {
|
||||
if (action.id == id) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DailyAction? selectedAction(String dateKey) {
|
||||
final todayActions = actionsForDate(dateKey);
|
||||
if (todayActions.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final selected = actionById(selectedActionId);
|
||||
if (selected != null &&
|
||||
selected.dateKey == dateKey &&
|
||||
!selected.isArchived) {
|
||||
return selected;
|
||||
}
|
||||
for (final action in todayActions) {
|
||||
if (action.status == DailyActionStatus.active ||
|
||||
action.status == DailyActionStatus.focused) {
|
||||
return action;
|
||||
}
|
||||
}
|
||||
return todayActions.first;
|
||||
}
|
||||
|
||||
List<FocusSession> sessionsForAction(String actionId) {
|
||||
return sessions
|
||||
.where((session) => session.actionId == actionId)
|
||||
.toList(growable: false)
|
||||
..sort((a, b) => b.completedAt.compareTo(a.completedAt));
|
||||
}
|
||||
|
||||
List<ProgressNote> progressNotesForAction(String actionId) {
|
||||
return progressNotes
|
||||
.where((note) => note.actionId == actionId)
|
||||
.toList(growable: false)
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
}
|
||||
|
||||
AppState copyWith({
|
||||
String? anchor,
|
||||
String? anchorDateKey,
|
||||
List<Habit>? habits,
|
||||
List<FocusSession>? sessions,
|
||||
List<ReflectionEntry>? reflections,
|
||||
List<DailyAction>? actions,
|
||||
String? selectedActionId,
|
||||
List<ProgressNote>? progressNotes,
|
||||
List<String>? recentLabels,
|
||||
int? defaultFocusMinutes,
|
||||
}) {
|
||||
return AppState(
|
||||
anchor: anchor ?? this.anchor,
|
||||
@@ -40,6 +126,11 @@ class AppState {
|
||||
habits: habits ?? this.habits,
|
||||
sessions: sessions ?? this.sessions,
|
||||
reflections: reflections ?? this.reflections,
|
||||
actions: actions ?? this.actions,
|
||||
selectedActionId: selectedActionId ?? this.selectedActionId,
|
||||
progressNotes: progressNotes ?? this.progressNotes,
|
||||
recentLabels: recentLabels ?? this.recentLabels,
|
||||
defaultFocusMinutes: defaultFocusMinutes ?? this.defaultFocusMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,11 +143,19 @@ class AppState {
|
||||
'sessions': sessions.map((session) => session.toJson()).toList(),
|
||||
'reflections':
|
||||
reflections.map((reflection) => reflection.toJson()).toList(),
|
||||
'actions': actions.map((action) => action.toJson()).toList(),
|
||||
'selectedActionId': selectedActionId,
|
||||
'progressNotes': progressNotes.map((note) => note.toJson()).toList(),
|
||||
'recentLabels': recentLabels,
|
||||
'defaultFocusMinutes': defaultFocusMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
static AppState fromJson(Map<String, dynamic> json) {
|
||||
final anchor = (json['anchor'] as String?) ?? '';
|
||||
final defaultMinutes = _validDefaultFocusMinutes(
|
||||
json['defaultFocusMinutes'] as int?,
|
||||
);
|
||||
return AppState(
|
||||
anchor: anchor,
|
||||
anchorDateKey:
|
||||
@@ -69,6 +168,14 @@ class AppState {
|
||||
_jsonObjects(
|
||||
json['reflections'],
|
||||
).map(ReflectionEntry.fromJson).toList(),
|
||||
actions: _jsonObjects(json['actions']).map(DailyAction.fromJson).toList(),
|
||||
selectedActionId: (json['selectedActionId'] as String?) ?? '',
|
||||
progressNotes:
|
||||
_jsonObjects(
|
||||
json['progressNotes'],
|
||||
).map(ProgressNote.fromJson).toList(),
|
||||
recentLabels: _stringList(json['recentLabels']),
|
||||
defaultFocusMinutes: defaultMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,6 +186,16 @@ class AppState {
|
||||
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}) {
|
||||
final now = DateTime.now();
|
||||
final titles =
|
||||
@@ -86,7 +203,7 @@ class AppState {
|
||||
? defaultHabitTitles!
|
||||
: const [
|
||||
'Plan one clear task',
|
||||
'Focus for 25 minutes',
|
||||
'Focus for 10 minutes',
|
||||
'Write one-line reflection',
|
||||
];
|
||||
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]) {
|
||||
final date = value ?? DateTime.now();
|
||||
return '${date.year.toString().padLeft(4, '0')}-'
|
||||
|
||||
@@ -3,25 +3,49 @@ class FocusSession {
|
||||
required this.id,
|
||||
required this.minutes,
|
||||
required this.completedAt,
|
||||
});
|
||||
this.intention = '',
|
||||
this.actionId,
|
||||
String? actionTitleSnapshot,
|
||||
int? actualSeconds,
|
||||
}) : actualSeconds = actualSeconds ?? minutes * 60,
|
||||
actionTitleSnapshot = actionTitleSnapshot ?? intention;
|
||||
|
||||
final String id;
|
||||
final int minutes;
|
||||
final int actualSeconds;
|
||||
final DateTime completedAt;
|
||||
final String intention;
|
||||
final String? actionId;
|
||||
final String actionTitleSnapshot;
|
||||
|
||||
int get actualMinutes => actualSeconds <= 0 ? 0 : (actualSeconds + 59) ~/ 60;
|
||||
String get actionTitle =>
|
||||
actionTitleSnapshot.trim().isEmpty
|
||||
? intention.trim()
|
||||
: actionTitleSnapshot.trim();
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'minutes': minutes,
|
||||
'actualSeconds': actualSeconds,
|
||||
'completedAt': completedAt.toIso8601String(),
|
||||
'intention': intention,
|
||||
'actionId': actionId,
|
||||
'actionTitleSnapshot': actionTitleSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
static FocusSession fromJson(Map<String, dynamic> json) {
|
||||
final minutes = json['minutes'] as int;
|
||||
return FocusSession(
|
||||
id: json['id'] as String,
|
||||
minutes: json['minutes'] as int,
|
||||
minutes: minutes,
|
||||
actualSeconds: (json['actualSeconds'] as int?) ?? minutes * 60,
|
||||
completedAt: DateTime.parse(json['completedAt'] as String),
|
||||
intention: (json['intention'] as String?) ?? '',
|
||||
actionId: json['actionId'] as String?,
|
||||
actionTitleSnapshot: json['actionTitleSnapshot'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import '../core/date_keys.dart';
|
||||
import 'app_state.dart';
|
||||
import 'growth_stage.dart';
|
||||
|
||||
class TodayProgress {
|
||||
const TodayProgress({
|
||||
@@ -10,7 +9,6 @@ class TodayProgress {
|
||||
required this.completedHabits,
|
||||
required this.totalHabits,
|
||||
required this.hasReflection,
|
||||
required this.growthStage,
|
||||
});
|
||||
|
||||
factory TodayProgress.fromState(AppState state, String dateKey) {
|
||||
@@ -23,8 +21,11 @@ class TodayProgress {
|
||||
.where((habit) => habit.completedDates.contains(dateKey))
|
||||
.length,
|
||||
totalHabits: state.habits.length,
|
||||
hasReflection: state.reflections.any((entry) => entry.dateKey == dateKey),
|
||||
growthStage: growthStageFor(state, dateKey),
|
||||
hasReflection:
|
||||
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 totalHabits;
|
||||
final bool hasReflection;
|
||||
final GrowthStage growthStage;
|
||||
|
||||
bool get hasIntention => intention.isNotEmpty;
|
||||
bool get hasFocus => focusMinutes > 0;
|
||||
bool get hasHabits => completedHabits > 0;
|
||||
bool get isLoopClosed => hasIntention && hasFocus && hasReflection;
|
||||
int get completedSteps =>
|
||||
[
|
||||
hasIntention,
|
||||
hasFocus,
|
||||
hasHabits,
|
||||
hasReflection,
|
||||
].where((complete) => complete).length;
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
enum PremiumState { free, premium }
|
||||
|
||||
enum AdPlacement { statsBottom, reflectionComplete }
|
||||
|
||||
sealed class PurchaseResult {
|
||||
const PurchaseResult();
|
||||
|
||||
static const unavailable = PurchaseUnavailable();
|
||||
}
|
||||
|
||||
class PurchaseUnavailable extends PurchaseResult {
|
||||
const PurchaseUnavailable();
|
||||
}
|
||||
|
||||
abstract interface class MonetizationProvider {
|
||||
bool get adsEnabled;
|
||||
bool get billingEnabled;
|
||||
Stream<PremiumState> premiumState();
|
||||
Future<void> showInterstitialIfEligible(AdPlacement placement);
|
||||
Future<PurchaseResult> startPurchase(String productId);
|
||||
Future<void> restorePurchases();
|
||||
}
|
||||
|
||||
class NoopMonetizationProvider implements MonetizationProvider {
|
||||
const NoopMonetizationProvider();
|
||||
|
||||
@override
|
||||
bool get adsEnabled => false;
|
||||
|
||||
@override
|
||||
bool get billingEnabled => false;
|
||||
|
||||
@override
|
||||
Stream<PremiumState> premiumState() => Stream.value(PremiumState.free);
|
||||
|
||||
@override
|
||||
Future<void> showInterstitialIfEligible(AdPlacement placement) async {}
|
||||
|
||||
@override
|
||||
Future<PurchaseResult> startPurchase(String productId) async {
|
||||
return PurchaseResult.unavailable;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restorePurchases() async {}
|
||||
}
|
||||
+452
-148
@@ -1,122 +1,262 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_theme.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'app_bottom_sheet.dart';
|
||||
import 'app_busy_indicator.dart';
|
||||
import 'surface_panel.dart';
|
||||
|
||||
Future<void> showHabitDialog(
|
||||
BuildContext context,
|
||||
ValueChanged<String> onSubmit, {
|
||||
Future<bool> Function(String value) onSubmit, {
|
||||
String initialValue = '',
|
||||
}) async {
|
||||
var value = initialValue;
|
||||
await showDialog<void>(
|
||||
final l10n = context.l10n;
|
||||
await showTextEntrySheet(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(
|
||||
initialValue.isEmpty
|
||||
? context.l10n.addHabitTitle
|
||||
: context.l10n.renameHabitTitle,
|
||||
),
|
||||
content: TextFormField(
|
||||
initialValue: initialValue,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: initialValue.isEmpty ? l10n.addHabitTitle : l10n.renameHabitTitle,
|
||||
hintText: l10n.habitHint,
|
||||
initialValue: initialValue,
|
||||
icon: Icons.add_task_rounded,
|
||||
accentBuilder: (context) => FocusSeedVisuals.of(context).habit,
|
||||
onSubmit: onSubmit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showIntentionDialog(
|
||||
BuildContext context,
|
||||
ValueChanged<String> onSubmit, {
|
||||
Future<bool> Function(String value) onSubmit, {
|
||||
String initialValue = '',
|
||||
}) async {
|
||||
var value = initialValue;
|
||||
await showDialog<void>(
|
||||
final l10n = context.l10n;
|
||||
await showTextEntrySheet(
|
||||
context: context,
|
||||
title:
|
||||
initialValue.isEmpty ? l10n.setIntentionTitle : l10n.editIntentionTitle,
|
||||
hintText: l10n.intentionHint,
|
||||
initialValue: initialValue,
|
||||
icon: Icons.spa_rounded,
|
||||
maxLength: 90,
|
||||
maxLines: 3,
|
||||
accentBuilder: (context) => FocusSeedVisuals.of(context).action,
|
||||
onSubmit: onSubmit,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showTextEntrySheet({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String hintText,
|
||||
required Future<bool> Function(String value) onSubmit,
|
||||
required IconData icon,
|
||||
required Color Function(BuildContext context) accentBuilder,
|
||||
String initialValue = '',
|
||||
int? maxLength,
|
||||
int minLines = 1,
|
||||
int maxLines = 1,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(
|
||||
initialValue.isEmpty
|
||||
? context.l10n.setIntentionTitle
|
||||
: context.l10n.editIntentionTitle,
|
||||
),
|
||||
content: TextFormField(
|
||||
initialValue: initialValue,
|
||||
autofocus: true,
|
||||
maxLength: 90,
|
||||
maxLines: 3,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(hintText: context.l10n.intentionHint),
|
||||
onChanged: (nextValue) => value = nextValue,
|
||||
onFieldSubmitted: (value) {
|
||||
onSubmit(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onSubmit(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.save),
|
||||
),
|
||||
],
|
||||
(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,
|
||||
textInputAction:
|
||||
widget.maxLines == 1
|
||||
? TextInputAction.done
|
||||
: TextInputAction.newline,
|
||||
onChanged: (_) {
|
||||
if (_errorText == null) {
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
setState(() => _errorText = null);
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
if (widget.maxLines == 1) {
|
||||
unawaited(_submit());
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
hintText: widget.hintText,
|
||||
errorText: _errorText,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton(
|
||||
onPressed:
|
||||
_saving || _controller.text.trim().isEmpty
|
||||
? null
|
||||
: _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: widget.accent,
|
||||
foregroundColor: foreground,
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
textStyle: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
child:
|
||||
_saving
|
||||
? AppBusyIndicator(
|
||||
semanticLabel: l10n.savingLabel,
|
||||
color: foreground,
|
||||
)
|
||||
: Text(l10n.save),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> confirmDeleteHabit(
|
||||
BuildContext context,
|
||||
VoidCallback onConfirm,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
await showConfirmSheet(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(context.l10n.deleteHabitTitle),
|
||||
content: Text(context.l10n.deleteHabitBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: context.l10n.deleteHabitTitle,
|
||||
body: context.l10n.deleteHabitBody,
|
||||
confirmLabel: context.l10n.delete,
|
||||
icon: Icons.delete_outline_rounded,
|
||||
accentBuilder: (context) => Theme.of(context).colorScheme.error,
|
||||
onConfirm: onConfirm,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,26 +264,14 @@ Future<void> confirmClearToday(
|
||||
BuildContext context,
|
||||
VoidCallback onConfirm,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
await showConfirmSheet(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(context.l10n.clearTodayDataTitle),
|
||||
content: Text(context.l10n.clearTodayDataBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.clearToday),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: context.l10n.clearTodayDataTitle,
|
||||
body: context.l10n.clearTodayDataBody,
|
||||
confirmLabel: context.l10n.clearToday,
|
||||
icon: Icons.refresh_rounded,
|
||||
accentBuilder: (context) => FocusSeedVisuals.of(context).warning,
|
||||
onConfirm: onConfirm,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,60 +279,236 @@ Future<void> confirmClearData(
|
||||
BuildContext context,
|
||||
VoidCallback onConfirm,
|
||||
) async {
|
||||
await showDialog<void>(
|
||||
await showConfirmSheet(
|
||||
context: context,
|
||||
title: context.l10n.clearAllDataTitle,
|
||||
body: context.l10n.clearAllDataBody,
|
||||
confirmLabel: context.l10n.deleteAll,
|
||||
icon: Icons.delete_forever_rounded,
|
||||
accentBuilder: (context) => Theme.of(context).colorScheme.error,
|
||||
onConfirm: onConfirm,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showConfirmSheet({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String body,
|
||||
required String confirmLabel,
|
||||
required IconData icon,
|
||||
required Color Function(BuildContext context) accentBuilder,
|
||||
required VoidCallback onConfirm,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(context.l10n.clearAllDataTitle),
|
||||
content: Text(context.l10n.clearAllDataBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.deleteAll),
|
||||
),
|
||||
],
|
||||
(context) => _ConfirmSheet(
|
||||
title: title,
|
||||
body: body,
|
||||
confirmLabel: confirmLabel,
|
||||
icon: icon,
|
||||
accent: accentBuilder(context),
|
||||
onConfirm: onConfirm,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
child: Text(
|
||||
body,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
height: 1.42,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_ConfirmActions(
|
||||
accent: accent,
|
||||
confirmLabel: confirmLabel,
|
||||
onConfirm: onConfirm,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showPrivacySheet(BuildContext context) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder:
|
||||
(context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
(context) => AppBottomSheetScaffold(
|
||||
title: context.l10n.privacyTitle,
|
||||
subtitle: context.l10n.privacySubtitle,
|
||||
icon: Icons.lock_outline_rounded,
|
||||
accent: FocusSeedVisuals.of(context).privacy,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.privacyTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
_PrivacyPoint(
|
||||
icon: Icons.phone_android_rounded,
|
||||
text: context.l10n.privacyBodyOne,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(context.l10n.privacyBodyOne),
|
||||
const SizedBox(height: 12),
|
||||
Text(context.l10n.privacyBodyTwo),
|
||||
_PrivacyPoint(
|
||||
icon: Icons.cleaning_services_rounded,
|
||||
text: context.l10n.privacyBodyTwo,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showSnack(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
class _PrivacyPoint extends StatelessWidget {
|
||||
const _PrivacyPoint({required this.icon, required this.text});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
return SurfacePanel(
|
||||
radius: 18,
|
||||
padding: const EdgeInsets.fromLTRB(13, 13, 13, 13),
|
||||
color: Color.alphaBlend(
|
||||
visuals.privacy.withValues(alpha: 0.045),
|
||||
scheme.surfaceContainerLowest,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: visuals.privacy, size: 21),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: scheme.onSurface,
|
||||
height: 1.42,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showSnack(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 92),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ConfirmActions extends StatelessWidget {
|
||||
const _ConfirmActions({
|
||||
required this.accent,
|
||||
required this.confirmLabel,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
final Color accent;
|
||||
final String confirmLabel;
|
||||
final VoidCallback onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final foreground =
|
||||
ThemeData.estimateBrightnessForColor(accent) == Brightness.dark
|
||||
? Colors.white
|
||||
: scheme.onSurface;
|
||||
final confirmButton = FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor: foreground,
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
textStyle: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
onConfirm();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(confirmLabel, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
final cancelButton = TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.cancel),
|
||||
);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1);
|
||||
if (constraints.maxWidth < 330 || textScale > 1.25) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [confirmButton, const SizedBox(height: 8), cancelButton],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: cancelButton),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(flex: 2, child: confirmButton),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,144 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_theme.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'app_motion.dart';
|
||||
|
||||
class LoadingView extends StatelessWidget {
|
||||
const LoadingView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
final theme = Theme.of(context);
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final l10n = context.l10n;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_LoadingMark(color: visuals.focus),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
l10n.loadingLocalData,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingMark extends StatefulWidget {
|
||||
const _LoadingMark({required this.color});
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
State<_LoadingMark> createState() => _LoadingMarkState();
|
||||
}
|
||||
|
||||
class _LoadingMarkState extends State<_LoadingMark>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1100),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (AppMotion.reduced(context)) {
|
||||
_controller.stop();
|
||||
_controller.value = 1;
|
||||
return;
|
||||
}
|
||||
if (!_controller.isAnimating) {
|
||||
_controller.repeat(reverse: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (AppMotion.reduced(context)) {
|
||||
return CustomPaint(
|
||||
size: const Size.square(44),
|
||||
painter: _LoadingMarkPainter(color: widget.color, progress: 1),
|
||||
);
|
||||
}
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final progress =
|
||||
0.58 + Curves.easeInOut.transform(_controller.value) * 0.42;
|
||||
return CustomPaint(
|
||||
size: const Size.square(44),
|
||||
painter: _LoadingMarkPainter(color: widget.color, progress: progress),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingMarkPainter extends CustomPainter {
|
||||
const _LoadingMarkPainter({required this.color, required this.progress});
|
||||
|
||||
final Color color;
|
||||
final double progress;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = size.center(Offset.zero);
|
||||
final seedRect = Rect.fromCenter(
|
||||
center: center,
|
||||
width: size.width * (0.52 + progress * 0.08),
|
||||
height: size.height * 0.42,
|
||||
);
|
||||
canvas.drawOval(seedRect, Paint()..color = color.withValues(alpha: 0.13));
|
||||
canvas.drawOval(
|
||||
seedRect.deflate(1),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.8
|
||||
..color = color.withValues(alpha: 0.28 + progress * 0.35),
|
||||
);
|
||||
final path =
|
||||
Path()
|
||||
..moveTo(size.width * 0.28, size.height * 0.54)
|
||||
..quadraticBezierTo(
|
||||
size.width * 0.48,
|
||||
size.height * 0.40,
|
||||
size.width * 0.72,
|
||||
size.height * 0.48,
|
||||
);
|
||||
final metric = path.computeMetrics().first;
|
||||
canvas.drawPath(
|
||||
metric.extractPath(0, metric.length * progress.clamp(0.08, 1)),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.6
|
||||
..strokeCap = StrokeCap.round
|
||||
..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _LoadingMarkPainter oldDelegate) {
|
||||
return oldDelegate.color != color || oldDelegate.progress != progress;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SettingsTile extends StatelessWidget {
|
||||
const SettingsTile({
|
||||
import 'workbench_ui.dart';
|
||||
|
||||
class SettingsSection extends StatelessWidget {
|
||||
const SettingsSection({
|
||||
required this.title,
|
||||
required this.children,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
WorkbenchPanel(
|
||||
accent: Theme.of(context).colorScheme.primary,
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) const Divider(indent: 16, endIndent: 16, height: 1),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsRow extends StatelessWidget {
|
||||
const SettingsRow({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
this.destructive = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
final bool destructive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = destructive ? Theme.of(context).colorScheme.error : null;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(icon, color: color),
|
||||
title: Text(title, style: TextStyle(color: color)),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: onTap,
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 10, 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.11),
|
||||
borderRadius: BorderRadius.circular(kWorkbenchRadius),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_theme.dart';
|
||||
import 'pressable_scale.dart';
|
||||
|
||||
enum SurfacePanelTone { plain, paper, wash }
|
||||
|
||||
class SurfacePanel extends StatelessWidget {
|
||||
const SurfacePanel({
|
||||
@@ -9,6 +12,8 @@ class SurfacePanel extends StatelessWidget {
|
||||
this.margin,
|
||||
this.color,
|
||||
this.borderColor,
|
||||
this.tone = SurfacePanelTone.plain,
|
||||
this.radius = 12,
|
||||
this.elevated = false,
|
||||
this.onTap,
|
||||
super.key,
|
||||
@@ -19,6 +24,8 @@ class SurfacePanel extends StatelessWidget {
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Color? color;
|
||||
final Color? borderColor;
|
||||
final SurfacePanelTone tone;
|
||||
final double radius;
|
||||
final bool elevated;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@@ -26,35 +33,64 @@ class SurfacePanel extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final visuals = FocusSeedVisuals.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final effectiveColor =
|
||||
color ??
|
||||
switch (tone) {
|
||||
SurfacePanelTone.plain => scheme.surfaceContainerLowest,
|
||||
SurfacePanelTone.paper =>
|
||||
scheme.brightness == Brightness.dark
|
||||
? scheme.surfaceContainerLow
|
||||
: AppTheme.paper,
|
||||
SurfacePanelTone.wash =>
|
||||
scheme.brightness == Brightness.dark
|
||||
? scheme.surfaceContainerLow
|
||||
: scheme.surfaceContainerLowest,
|
||||
};
|
||||
final effectiveBorderColor =
|
||||
borderColor ??
|
||||
(tone == SurfacePanelTone.wash
|
||||
? visuals.focus.withValues(alpha: 0.06)
|
||||
: scheme.outlineVariant.withValues(alpha: 0.0));
|
||||
final decoration = BoxDecoration(
|
||||
color: color ?? theme.colorScheme.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: borderColor ?? theme.colorScheme.outlineVariant,
|
||||
),
|
||||
boxShadow:
|
||||
elevated
|
||||
? [
|
||||
BoxShadow(
|
||||
color: visuals.shadow,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
color: effectiveColor,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
border:
|
||||
effectiveBorderColor.a == 0
|
||||
? null
|
||||
: Border.all(color: effectiveBorderColor),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: visuals.shadow.withValues(
|
||||
alpha:
|
||||
theme.colorScheme.brightness == Brightness.dark
|
||||
? elevated
|
||||
? 0.22
|
||||
: 0.11
|
||||
: elevated
|
||||
? 0.11
|
||||
: 0.045,
|
||||
),
|
||||
blurRadius: elevated ? 22 : 12,
|
||||
offset: Offset(0, elevated ? 8 : 4),
|
||||
),
|
||||
],
|
||||
);
|
||||
final content = Padding(padding: padding, child: child);
|
||||
return Container(
|
||||
margin: margin,
|
||||
decoration: decoration,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child:
|
||||
onTap == null
|
||||
? content
|
||||
: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(onTap: onTap, child: content),
|
||||
),
|
||||
return PressableScale(
|
||||
enabled: onTap != null,
|
||||
child: Container(
|
||||
margin: margin,
|
||||
decoration: decoration,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child:
|
||||
onTap == null
|
||||
? content
|
||||
: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(onTap: onTap, child: content),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -69,7 +69,8 @@ flutter:
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/images/growth/
|
||||
- assets/illustrations/onboarding/
|
||||
- assets/illustrations/records/
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
|
||||
@@ -12,6 +12,8 @@ import 'package:focus_seed/src/models/reflection_entry.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
@@ -77,4 +79,99 @@ void main() {
|
||||
'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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,13 @@ void main() {
|
||||
),
|
||||
],
|
||||
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: [
|
||||
ReflectionEntry(
|
||||
@@ -63,6 +69,7 @@ void main() {
|
||||
createdAt: now.add(const Duration(minutes: 1)),
|
||||
),
|
||||
],
|
||||
defaultFocusMinutes: 45,
|
||||
);
|
||||
|
||||
await store.save(state);
|
||||
@@ -72,7 +79,15 @@ void main() {
|
||||
expect(loaded.anchorDateKey, '2026-06-12');
|
||||
expect(loaded.habits.single.completedDates, {'2026-06-11', '2026-06-12'});
|
||||
expect(loaded.sessions.single.minutes, 25);
|
||||
expect(loaded.sessions.single.actualSeconds, 90);
|
||||
expect(loaded.sessions.single.actualMinutes, 2);
|
||||
expect(loaded.sessions.single.intention, 'Ship the local database');
|
||||
expect(loaded.reflections.single.note, 'Small scope kept it clean.');
|
||||
expect(loaded.defaultFocusMinutes, 45);
|
||||
final row = await database.select(database.focusSessionRows).getSingle();
|
||||
expect(row.plannedMinutes, 25);
|
||||
expect(row.actualSeconds, 90);
|
||||
expect(row.intentionText, 'Ship the local database');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -1,9 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_seed/src/app/focus_seed_app.dart';
|
||||
import 'package:focus_seed/src/data/focus_seed_store.dart';
|
||||
import 'package:focus_seed/src/data/local/focus_seed_database.dart';
|
||||
import 'package:focus_seed/src/models/app_state.dart';
|
||||
import 'package:focus_seed/src/models/focus_session.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
@@ -16,45 +20,352 @@ void main() {
|
||||
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'));
|
||||
|
||||
expect(find.text('Focus Seed'), findsOneWidget);
|
||||
expect(find.text("TODAY'S FOCUS"), findsOneWidget);
|
||||
expect(find.text("Set today's focus"), findsOneWidget);
|
||||
expect(find.text('Start 10 min'), findsWidgets);
|
||||
expect(find.text('Name today\'s focus'), findsOneWidget);
|
||||
expect(find.text('Pick a focus plan'), findsNothing);
|
||||
expect(find.text('Deep work sprint'), findsNothing);
|
||||
});
|
||||
|
||||
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.enterText(
|
||||
find.byType(TextField),
|
||||
'Finish the launch checklist',
|
||||
);
|
||||
await tester.ensureVisible(find.text('Start 10 minutes'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Start 10 minutes'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Today\'s focus: Finish the launch checklist'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('10 min'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('finished onboarding restores landing until it is completed', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'focus_seed_onboarding_complete': true,
|
||||
'focus_seed_landing_setup_complete': false,
|
||||
});
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
await tester.pumpWidget(
|
||||
FocusSeedApp(
|
||||
locale: const Locale('en'),
|
||||
focusSeedStore: FocusSeedStore(database: database),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Start with 10 minutes.'), findsOneWidget);
|
||||
expect(find.text('Focus Seed'), findsNothing);
|
||||
|
||||
await tester.ensureVisible(find.text('Enter Today'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Enter Today'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Focus Seed'), findsOneWidget);
|
||||
expect(find.text('Start with 10 minutes.'), findsNothing);
|
||||
|
||||
await tester.pumpWidget(
|
||||
FocusSeedApp(
|
||||
locale: const Locale('en'),
|
||||
focusSeedStore: FocusSeedStore(database: database),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Focus Seed'), findsOneWidget);
|
||||
expect(find.text('Start with 10 minutes.'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('bottom navigation exposes Focus, Records, and Me', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpFocusSeedApp(tester, locale: const Locale('en'));
|
||||
|
||||
await tester.tap(_navDestination('Focus'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Ready when you are'), findsOneWidget);
|
||||
|
||||
await tester.tap(_navDestination('Records'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Records'), findsWidgets);
|
||||
expect(find.text('No progress records yet'), findsOneWidget);
|
||||
|
||||
await tester.tap(_navDestination('Me'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Me'), findsWidgets);
|
||||
expect(find.text('Preferences'), findsWidgets);
|
||||
|
||||
await tester.tap(_navDestination('Today'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Name today\'s focus'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('V1 action loop records a session and capture', (tester) async {
|
||||
await pumpFocusSeedApp(tester, locale: const Locale('en'));
|
||||
|
||||
final startButton = find.ancestor(
|
||||
of: find.text('Start 10 min').last,
|
||||
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
|
||||
);
|
||||
await tester.ensureVisible(startButton);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(startButton);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Deep work in progress'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Pause'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Paused - your place is kept'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Resume'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Deep work in progress'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Complete session'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Session complete'), findsOneWidget);
|
||||
expect(find.text('Write progress note'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Write progress note'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Leave one progress note'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).last, 'Finished one pass.');
|
||||
await tester.tap(find.text('Save note'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(_navDestination('Records'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Records'), findsWidgets);
|
||||
expect(find.text('Finished one pass.'), findsOneWidget);
|
||||
expect(find.text('No progress records yet'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Records can backfill reflection for a past daily record', (
|
||||
tester,
|
||||
) async {
|
||||
final database = FocusSeedDatabase(NativeDatabase.memory());
|
||||
addTearDown(database.close);
|
||||
final store = FocusSeedStore(database: database);
|
||||
final now = DateTime.now();
|
||||
final yesterday = now.subtract(const Duration(days: 1));
|
||||
await store.save(
|
||||
AppState(
|
||||
anchor: '',
|
||||
anchorDateKey: '',
|
||||
habits: const [],
|
||||
sessions: [
|
||||
FocusSession(
|
||||
id: 'session-yesterday',
|
||||
minutes: 10,
|
||||
actualSeconds: 600,
|
||||
completedAt: yesterday,
|
||||
intention: 'Draft launch notes',
|
||||
),
|
||||
],
|
||||
reflections: const [],
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
FocusSeedApp(
|
||||
locale: const Locale('en'),
|
||||
focusSeedStore: store,
|
||||
showWelcomeOnFirstRun: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(_navDestination('Records'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Draft launch notes'), findsWidgets);
|
||||
expect(find.text('No progress note yet'), findsWidgets);
|
||||
expect(find.text('Write one sentence to close today'), findsOneWidget);
|
||||
expect(find.text('Continue this'), findsNothing);
|
||||
|
||||
final reflectionButton = find.ancestor(
|
||||
of: find.text('Write one sentence to close today'),
|
||||
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
|
||||
);
|
||||
await tester.ensureVisible(reflectionButton);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(reflectionButton);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Leave one progress note'), findsOneWidget);
|
||||
expect(find.text('TODAY\'S FOCUS'), findsWidgets);
|
||||
expect(find.text('Draft launch notes'), findsWidgets);
|
||||
await tester.enterText(find.byType(TextField).last, 'Next: polish copy.');
|
||||
final saveNoteButton = find.ancestor(
|
||||
of: find.text('Save note'),
|
||||
matching: find.byWidgetPredicate((widget) => widget is ButtonStyleButton),
|
||||
);
|
||||
await tester.ensureVisible(saveNoteButton);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(saveNoteButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Next: polish copy.'), findsOneWidget);
|
||||
expect(find.text('Write one sentence to close today'), findsNothing);
|
||||
expect(find.text('Continue this'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('settings manages local data without JSON tools', (tester) async {
|
||||
await pumpFocusSeedApp(tester, locale: const Locale('en'));
|
||||
|
||||
await tester.tap(_navDestination('Me'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
var meScroll = find.byType(Scrollable).first;
|
||||
expect(find.text('Personal rhythm'), findsNothing);
|
||||
expect(find.text('Saved plans'), findsNothing);
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Theme, language, and default focus'),
|
||||
180,
|
||||
scrollable: meScroll,
|
||||
);
|
||||
final preferencesRow = find.ancestor(
|
||||
of: find.text('Theme, language, and default focus'),
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
expect(preferencesRow, findsOneWidget);
|
||||
await tester.tap(preferencesRow);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Default focus'), findsOneWidget);
|
||||
expect(find.text('10 min'), findsWidgets);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('5'), findsOneWidget);
|
||||
expect(find.text('45'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.add_rounded).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Custom length'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), '7');
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Finish the launch checklist'), findsOneWidget);
|
||||
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'));
|
||||
expect(find.text('7 min'), findsOneWidget);
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Progress'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Me'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Your garden'), findsOneWidget);
|
||||
});
|
||||
|
||||
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;
|
||||
meScroll = find.byType(Scrollable).first;
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Local records'),
|
||||
180,
|
||||
scrollable: meScroll,
|
||||
);
|
||||
expect(find.text('Local records'), findsOneWidget);
|
||||
expect(find.text('Clear today'), findsOneWidget);
|
||||
await tester.scrollUntilVisible(
|
||||
@@ -67,35 +378,17 @@ void main() {
|
||||
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 tester.tap(find.text('Journey'));
|
||||
await tester.tap(_navDestination('Records'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final journeyScroll = find.byType(Scrollable).first;
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('7-day rhythm'),
|
||||
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);
|
||||
expect(find.text('Records'), findsWidgets);
|
||||
expect(find.text('No progress records yet'), findsOneWidget);
|
||||
expect(find.text('Back to Today'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Today remains usable at narrow width and 200 percent text', (
|
||||
@@ -121,16 +414,16 @@ void main() {
|
||||
await pumpFocusSeedApp(tester);
|
||||
|
||||
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(
|
||||
find.text('\u8ba1\u5212\u4e00\u4e2a\u660e\u786e\u4efb\u52a1'),
|
||||
find.text('\u5199\u4e0b\u4eca\u5929\u7684\u91cd\u70b9'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('\u5f00\u59cb 10 \u5206\u949f'), findsWidgets);
|
||||
expect(
|
||||
find.text('\u9009\u62e9\u4e00\u4e2a\u4e13\u6ce8\u8ba1\u5212'),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('\u6df1\u5ea6\u5de5\u4f5c'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('development language switch changes the active locale', (
|
||||
@@ -138,15 +431,36 @@ void main() {
|
||||
) async {
|
||||
await pumpFocusSeedApp(tester);
|
||||
|
||||
await tester.tap(find.text('\u6211\u7684'));
|
||||
await tester.tap(_navDestination('\u6211\u7684'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final settingsScroll = find.byType(Scrollable).first;
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('\u4e3b\u9898\u3001\u8bed\u8a00\u548c\u9ed8\u8ba4\u4e13\u6ce8'),
|
||||
180,
|
||||
scrollable: settingsScroll,
|
||||
);
|
||||
final preferencesRow = find.ancestor(
|
||||
of: find.text(
|
||||
'\u4e3b\u9898\u3001\u8bed\u8a00\u548c\u9ed8\u8ba4\u4e13\u6ce8',
|
||||
),
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
await tester.tap(preferencesRow);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('\u8bed\u8a00'), findsOneWidget);
|
||||
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();
|
||||
|
||||
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(
|
||||
locale: locale,
|
||||
focusSeedStore: FocusSeedStore(database: database),
|
||||
showWelcomeOnFirstRun: false,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Finder _navDestination(String label) {
|
||||
final key = switch (label) {
|
||||
'Today' || '\u4eca\u65e5' => 'today',
|
||||
'Focus' || '\u4e13\u6ce8' => 'focus',
|
||||
'Records' || '\u8bb0\u5f55' => 'records',
|
||||
'Me' || '\u6211\u7684' => 'me',
|
||||
_ => label.toLowerCase(),
|
||||
};
|
||||
return find.byKey(ValueKey('focus-seed-nav-$key'));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user