diff --git a/README.md b/README.md index 1154f23..dba118d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/assets/images/growth/growth-flower.png b/assets/images/growth/growth-flower.png deleted file mode 100644 index 6187c3c..0000000 Binary files a/assets/images/growth/growth-flower.png and /dev/null differ diff --git a/assets/images/growth/growth-leaves.png b/assets/images/growth/growth-leaves.png deleted file mode 100644 index 035debc..0000000 Binary files a/assets/images/growth/growth-leaves.png and /dev/null differ diff --git a/assets/images/growth/growth-seed.png b/assets/images/growth/growth-seed.png deleted file mode 100644 index dbbdbf3..0000000 Binary files a/assets/images/growth/growth-seed.png and /dev/null differ diff --git a/assets/images/growth/growth-sprout.png b/assets/images/growth/growth-sprout.png deleted file mode 100644 index e648ffe..0000000 Binary files a/assets/images/growth/growth-sprout.png and /dev/null differ diff --git a/docs/architecture/v1.0.1-architecture.md b/docs/architecture/v1.0.1-architecture.md index c71ecba..f32d224 100644 --- a/docs/architecture/v1.0.1-architecture.md +++ b/docs/architecture/v1.0.1-architecture.md @@ -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. diff --git a/docs/design/focus-seed-living-ambience-v1.0.1.png b/docs/design/focus-seed-living-ambience-v1.0.1.png deleted file mode 100644 index 2054c93..0000000 Binary files a/docs/design/focus-seed-living-ambience-v1.0.1.png and /dev/null differ diff --git a/docs/design/focus-seed-visual-direction-v1.0.1.png b/docs/design/focus-seed-visual-direction-v1.0.1.png deleted file mode 100644 index c84c748..0000000 Binary files a/docs/design/focus-seed-visual-direction-v1.0.1.png and /dev/null differ diff --git a/docs/design/focus-seed-visual-system-v1.0.2.md b/docs/design/focus-seed-visual-system-v1.0.2.md index 8bec3f4..dd30687 100644 --- a/docs/design/focus-seed-visual-system-v1.0.2.md +++ b/docs/design/focus-seed-visual-system-v1.0.2.md @@ -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. diff --git a/docs/design/visual-direction-v1.0.1.md b/docs/design/visual-direction-v1.0.1.md deleted file mode 100644 index 75240c7..0000000 --- a/docs/design/visual-direction-v1.0.1.md +++ /dev/null @@ -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. diff --git a/docs/roadmaps/v1.0.2-local-database-product-loop.md b/docs/roadmaps/v1.0.2-local-database-product-loop.md index 33af19b..5afe7ed 100644 --- a/docs/roadmaps/v1.0.2-local-database-product-loop.md +++ b/docs/roadmaps/v1.0.2-local-database-product-loop.md @@ -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. diff --git a/launch/architecture.md b/launch/architecture.md index 8c4f941..221181f 100644 --- a/launch/architecture.md +++ b/launch/architecture.md @@ -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. diff --git a/launch/play-console/closed-testing-plan.md b/launch/play-console/closed-testing-plan.md index 73e016f..f378522 100644 --- a/launch/play-console/closed-testing-plan.md +++ b/launch/play-console/closed-testing-plan.md @@ -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. diff --git a/launch/play-console/data-safety-guide.md b/launch/play-console/data-safety-guide.md index 8c78273..10a2dca 100644 --- a/launch/play-console/data-safety-guide.md +++ b/launch/play-console/data-safety-guide.md @@ -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 diff --git a/launch/play-console/store-listing.md b/launch/play-console/store-listing.md index c119bb7..1165ae2 100644 --- a/launch/play-console/store-listing.md +++ b/launch/play-console/store-listing.md @@ -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 diff --git a/launch/privacy/privacy-policy.html b/launch/privacy/privacy-policy.html index 9fdc47c..51a80dd 100644 --- a/launch/privacy/privacy-policy.html +++ b/launch/privacy/privacy-policy.html @@ -71,17 +71,15 @@
  • 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

    Focus Seed V1 does not include advertising SDKs, analytics SDKs, or in-app purchase SDKs.

    diff --git a/launch/privacy/privacy-policy.md b/launch/privacy/privacy-policy.md index 8578017..7565bb7 100644 --- a/launch/privacy/privacy-policy.md +++ b/launch/privacy/privacy-policy.md @@ -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 diff --git a/launch/product-brief.md b/launch/product-brief.md index 07d2239..2bc2aa2 100644 --- a/launch/product-brief.md +++ b/launch/product-brief.md @@ -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 diff --git a/launch/roadmaps/v1.0.1-experience-brief.md b/launch/roadmaps/v1.0.1-experience-brief.md index 57a0ed6..08d8690 100644 --- a/launch/roadmaps/v1.0.1-experience-brief.md +++ b/launch/roadmaps/v1.0.1-experience-brief.md @@ -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 diff --git a/launch/screenshots/screenshot-plan.md b/launch/screenshots/screenshot-plan.md index 0e662d8..160a815 100644 --- a/launch/screenshots/screenshot-plan.md +++ b/launch/screenshots/screenshot-plan.md @@ -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: diff --git a/lib/src/app/app_theme.dart b/lib/src/app/app_theme.dart index 2039c3e..01dcda6 100644 --- a/lib/src/app/app_theme.dart +++ b/lib/src/app/app_theme.dart @@ -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 { 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 { 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 { } 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)!, ); } diff --git a/lib/src/app/focus_seed_app.dart b/lib/src/app/focus_seed_app.dart index 74e2880..3f3e2a7 100644 --- a/lib/src/app/focus_seed_app.dart +++ b/lib/src/app/focus_seed_app.dart @@ -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 createState() => _FocusSeedAppState(); @@ -33,6 +41,10 @@ class _FocusSeedAppState extends State 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 } Future _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 return widget.locale ?? _language.locale ?? focusSeedDefaultLocale(); } + Future _completeOnboarding() async { + setState(() { + _onboardingComplete = true; + _showLandingSetup = true; + }); + await _preferencesStore.saveOnboardingComplete(); + } + + Future _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 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()); + }, + ), ); } diff --git a/lib/src/application/focus_seed_controller.dart b/lib/src/application/focus_seed_controller.dart index 9903f37..15a3c85 100644 --- a/lib/src/application/focus_seed_controller.dart +++ b/lib/src/application/focus_seed_controller.dart @@ -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 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 toggleHabit(Habit habit) async { @@ -128,46 +135,86 @@ class FocusSeedController extends ChangeNotifier { return _commit(current.copyWith(habits: habits)); } - Future completeFocusSession(int minutes) async { + Future 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 saveReflection(String value, Mood mood) async { + return saveReflectionForDate(todayKey(), value, mood); + } + + Future 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 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 clearData() async { - return _commit(AppState.starter(defaultHabitTitles: _defaultHabitTitles)); + final current = _state; + return _commit( + AppState.starter(defaultHabitTitles: _defaultHabitTitles).copyWith( + defaultFocusMinutes: + current?.defaultFocusMinutes ?? defaultFocusDurationMinutes, + ), + ); } Future 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, ), ); diff --git a/lib/src/application/focus_timer_controller.dart b/lib/src/application/focus_timer_controller.dart index 278387e..87c1978 100644 --- a/lib/src/application/focus_timer_controller.dart +++ b/lib/src/application/focus_timer_controller.dart @@ -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 Function(int minutes, int actualSeconds); + class FocusTimerController extends ChangeNotifier with WidgetsBindingObserver { FocusTimerController({ - required Future 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 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 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 _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 _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 _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 _clearSavedSession() async { + await _sessionStore?.clear(); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); diff --git a/lib/src/core/date_keys.dart b/lib/src/core/date_keys.dart index cc0931e..c811502 100644 --- a/lib/src/core/date_keys.dart +++ b/lib/src/core/date_keys.dart @@ -22,7 +22,7 @@ List lastSevenDateKeys() { int minutesForDate(List sessions, String key) { return sessions .where((session) => dateKey(session.completedAt) == key) - .fold(0, (total, session) => total + session.minutes); + .fold(0, (total, session) => total + session.actualMinutes); } int currentStreak( diff --git a/lib/src/data/app_preferences_store.dart b/lib/src/data/app_preferences_store.dart index 8430a26..9371100 100644 --- a/lib/src/data/app_preferences_store.dart +++ b/lib/src/data/app_preferences_store.dart @@ -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 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 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 saveOnboardingComplete() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setBool(_onboardingCompleteKey, true); + if (!preferences.containsKey(_landingSetupCompleteKey)) { + await preferences.setBool(_landingSetupCompleteKey, false); + } + } + + Future saveLandingSetupComplete() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setBool(_onboardingCompleteKey, true); + await preferences.setBool(_landingSetupCompleteKey, true); + await preferences.setBool(_legacyWelcomeCompleteKey, true); + } } diff --git a/lib/src/data/focus_seed_store.dart b/lib/src/data/focus_seed_store.dart index 71c3709..7ee277b 100644 --- a/lib/src/data/focus_seed_store.dart +++ b/lib/src/data/focus_seed_store.dart @@ -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 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 = >{}; 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 _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 _migrateLegacyJsonIfNeeded() async { final preferences = await SharedPreferences.getInstance(); if (preferences.getBool(_legacyMigrationKey) ?? false) { @@ -206,6 +295,12 @@ class FocusSeedStore { } Future _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 _decodeLabels(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + 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', ); diff --git a/lib/src/data/local/focus_seed_database.dart b/lib/src/data/local/focus_seed_database.dart index c57cf03..a705914 100644 --- a/lib/src/data/local/focus_seed_database.dart +++ b/lib/src/data/local/focus_seed_database.dart @@ -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> 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> 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> 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> 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 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(); }); diff --git a/lib/src/data/local/focus_seed_database.g.dart b/lib/src/data/local/focus_seed_database.g.dart index 5a1fe56..b19ae53 100644 --- a/lib/src/data/local/focus_seed_database.g.dart +++ b/lib/src/data/local/focus_seed_database.g.dart @@ -331,6 +331,622 @@ class DailyIntentionRowsCompanion extends UpdateCompanion { } } +class $DailyActionRowsTable extends DailyActionRows + with TableInfo<$DailyActionRowsTable, DailyActionRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $DailyActionRowsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _dateKeyMeta = const VerificationMeta( + 'dateKey', + ); + @override + late final GeneratedColumn dateKey = GeneratedColumn( + 'date_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titleMeta = const VerificationMeta('title'); + @override + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _orderIndexMeta = const VerificationMeta( + 'orderIndex', + ); + @override + late final GeneratedColumn orderIndex = GeneratedColumn( + 'order_index', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _labelsJsonMeta = const VerificationMeta( + 'labelsJson', + ); + @override + late final GeneratedColumn labelsJson = GeneratedColumn( + 'labels_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _archivedAtMeta = const VerificationMeta( + 'archivedAt', + ); + @override + late final GeneratedColumn archivedAt = GeneratedColumn( + 'archived_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _sourceMeta = const VerificationMeta('source'); + @override + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + dateKey, + title, + status, + orderIndex, + labelsJson, + createdAt, + updatedAt, + archivedAt, + source, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'daily_actions'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('date_key')) { + context.handle( + _dateKeyMeta, + dateKey.isAcceptableOrUnknown(data['date_key']!, _dateKeyMeta), + ); + } else if (isInserting) { + context.missing(_dateKeyMeta); + } + if (data.containsKey('title')) { + context.handle( + _titleMeta, + title.isAcceptableOrUnknown(data['title']!, _titleMeta), + ); + } else if (isInserting) { + context.missing(_titleMeta); + } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } else if (isInserting) { + context.missing(_statusMeta); + } + if (data.containsKey('order_index')) { + context.handle( + _orderIndexMeta, + orderIndex.isAcceptableOrUnknown(data['order_index']!, _orderIndexMeta), + ); + } else if (isInserting) { + context.missing(_orderIndexMeta); + } + if (data.containsKey('labels_json')) { + context.handle( + _labelsJsonMeta, + labelsJson.isAcceptableOrUnknown(data['labels_json']!, _labelsJsonMeta), + ); + } else if (isInserting) { + context.missing(_labelsJsonMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + if (data.containsKey('archived_at')) { + context.handle( + _archivedAtMeta, + archivedAt.isAcceptableOrUnknown(data['archived_at']!, _archivedAtMeta), + ); + } + if (data.containsKey('source')) { + context.handle( + _sourceMeta, + source.isAcceptableOrUnknown(data['source']!, _sourceMeta), + ); + } else if (isInserting) { + context.missing(_sourceMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + DailyActionRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return DailyActionRow( + id: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + dateKey: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}date_key'], + )!, + title: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}title'], + )!, + status: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + orderIndex: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}order_index'], + )!, + labelsJson: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}labels_json'], + )!, + createdAt: + attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: + attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + archivedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}archived_at'], + ), + source: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source'], + )!, + ); + } + + @override + $DailyActionRowsTable createAlias(String alias) { + return $DailyActionRowsTable(attachedDatabase, alias); + } +} + +class DailyActionRow extends DataClass implements Insertable { + final String id; + final String dateKey; + final String title; + final String status; + final int orderIndex; + final String labelsJson; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? archivedAt; + final String source; + const DailyActionRow({ + required this.id, + required this.dateKey, + required this.title, + required this.status, + required this.orderIndex, + required this.labelsJson, + required this.createdAt, + required this.updatedAt, + this.archivedAt, + required this.source, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['date_key'] = Variable(dateKey); + map['title'] = Variable(title); + map['status'] = Variable(status); + map['order_index'] = Variable(orderIndex); + map['labels_json'] = Variable(labelsJson); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || archivedAt != null) { + map['archived_at'] = Variable(archivedAt); + } + map['source'] = Variable(source); + return map; + } + + DailyActionRowsCompanion toCompanion(bool nullToAbsent) { + return DailyActionRowsCompanion( + id: Value(id), + dateKey: Value(dateKey), + title: Value(title), + status: Value(status), + orderIndex: Value(orderIndex), + labelsJson: Value(labelsJson), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + archivedAt: + archivedAt == null && nullToAbsent + ? const Value.absent() + : Value(archivedAt), + source: Value(source), + ); + } + + factory DailyActionRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return DailyActionRow( + id: serializer.fromJson(json['id']), + dateKey: serializer.fromJson(json['dateKey']), + title: serializer.fromJson(json['title']), + status: serializer.fromJson(json['status']), + orderIndex: serializer.fromJson(json['orderIndex']), + labelsJson: serializer.fromJson(json['labelsJson']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + archivedAt: serializer.fromJson(json['archivedAt']), + source: serializer.fromJson(json['source']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'dateKey': serializer.toJson(dateKey), + 'title': serializer.toJson(title), + 'status': serializer.toJson(status), + 'orderIndex': serializer.toJson(orderIndex), + 'labelsJson': serializer.toJson(labelsJson), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'archivedAt': serializer.toJson(archivedAt), + 'source': serializer.toJson(source), + }; + } + + DailyActionRow copyWith({ + String? id, + String? dateKey, + String? title, + String? status, + int? orderIndex, + String? labelsJson, + DateTime? createdAt, + DateTime? updatedAt, + Value archivedAt = const Value.absent(), + String? source, + }) => DailyActionRow( + id: id ?? this.id, + dateKey: dateKey ?? this.dateKey, + title: title ?? this.title, + status: status ?? this.status, + orderIndex: orderIndex ?? this.orderIndex, + labelsJson: labelsJson ?? this.labelsJson, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, + source: source ?? this.source, + ); + DailyActionRow copyWithCompanion(DailyActionRowsCompanion data) { + return DailyActionRow( + id: data.id.present ? data.id.value : this.id, + dateKey: data.dateKey.present ? data.dateKey.value : this.dateKey, + title: data.title.present ? data.title.value : this.title, + status: data.status.present ? data.status.value : this.status, + orderIndex: + data.orderIndex.present ? data.orderIndex.value : this.orderIndex, + labelsJson: + data.labelsJson.present ? data.labelsJson.value : this.labelsJson, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + archivedAt: + data.archivedAt.present ? data.archivedAt.value : this.archivedAt, + source: data.source.present ? data.source.value : this.source, + ); + } + + @override + String toString() { + return (StringBuffer('DailyActionRow(') + ..write('id: $id, ') + ..write('dateKey: $dateKey, ') + ..write('title: $title, ') + ..write('status: $status, ') + ..write('orderIndex: $orderIndex, ') + ..write('labelsJson: $labelsJson, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('archivedAt: $archivedAt, ') + ..write('source: $source') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + dateKey, + title, + status, + orderIndex, + labelsJson, + createdAt, + updatedAt, + archivedAt, + source, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is DailyActionRow && + other.id == this.id && + other.dateKey == this.dateKey && + other.title == this.title && + other.status == this.status && + other.orderIndex == this.orderIndex && + other.labelsJson == this.labelsJson && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.archivedAt == this.archivedAt && + other.source == this.source); +} + +class DailyActionRowsCompanion extends UpdateCompanion { + final Value id; + final Value dateKey; + final Value title; + final Value status; + final Value orderIndex; + final Value labelsJson; + final Value createdAt; + final Value updatedAt; + final Value archivedAt; + final Value source; + final Value rowid; + const DailyActionRowsCompanion({ + this.id = const Value.absent(), + this.dateKey = const Value.absent(), + this.title = const Value.absent(), + this.status = const Value.absent(), + this.orderIndex = const Value.absent(), + this.labelsJson = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.archivedAt = const Value.absent(), + this.source = const Value.absent(), + this.rowid = const Value.absent(), + }); + DailyActionRowsCompanion.insert({ + required String id, + required String dateKey, + required String title, + required String status, + required int orderIndex, + required String labelsJson, + required DateTime createdAt, + required DateTime updatedAt, + this.archivedAt = const Value.absent(), + required String source, + this.rowid = const Value.absent(), + }) : id = Value(id), + dateKey = Value(dateKey), + title = Value(title), + status = Value(status), + orderIndex = Value(orderIndex), + labelsJson = Value(labelsJson), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt), + source = Value(source); + static Insertable custom({ + Expression? id, + Expression? dateKey, + Expression? title, + Expression? status, + Expression? orderIndex, + Expression? labelsJson, + Expression? createdAt, + Expression? updatedAt, + Expression? archivedAt, + Expression? source, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (dateKey != null) 'date_key': dateKey, + if (title != null) 'title': title, + if (status != null) 'status': status, + if (orderIndex != null) 'order_index': orderIndex, + if (labelsJson != null) 'labels_json': labelsJson, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (archivedAt != null) 'archived_at': archivedAt, + if (source != null) 'source': source, + if (rowid != null) 'rowid': rowid, + }); + } + + DailyActionRowsCompanion copyWith({ + Value? id, + Value? dateKey, + Value? title, + Value? status, + Value? orderIndex, + Value? labelsJson, + Value? createdAt, + Value? updatedAt, + Value? archivedAt, + Value? source, + Value? rowid, + }) { + return DailyActionRowsCompanion( + id: id ?? this.id, + dateKey: dateKey ?? this.dateKey, + title: title ?? this.title, + status: status ?? this.status, + orderIndex: orderIndex ?? this.orderIndex, + labelsJson: labelsJson ?? this.labelsJson, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + archivedAt: archivedAt ?? this.archivedAt, + source: source ?? this.source, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (dateKey.present) { + map['date_key'] = Variable(dateKey.value); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (orderIndex.present) { + map['order_index'] = Variable(orderIndex.value); + } + if (labelsJson.present) { + map['labels_json'] = Variable(labelsJson.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (archivedAt.present) { + map['archived_at'] = Variable(archivedAt.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('DailyActionRowsCompanion(') + ..write('id: $id, ') + ..write('dateKey: $dateKey, ') + ..write('title: $title, ') + ..write('status: $status, ') + ..write('orderIndex: $orderIndex, ') + ..write('labelsJson: $labelsJson, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('archivedAt: $archivedAt, ') + ..write('source: $source, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $FocusSessionRowsTable extends FocusSessionRows with TableInfo<$FocusSessionRowsTable, FocusSessionRow> { @override @@ -357,6 +973,39 @@ class $FocusSessionRowsTable extends FocusSessionRows type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _actionIdMeta = const VerificationMeta( + 'actionId', + ); + @override + late final GeneratedColumn actionId = GeneratedColumn( + 'action_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _actionTitleSnapshotMeta = + const VerificationMeta('actionTitleSnapshot'); + @override + late final GeneratedColumn actionTitleSnapshot = + GeneratedColumn( + 'action_title_snapshot', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _intentionTextMeta = const VerificationMeta( + 'intentionText', + ); + @override + late final GeneratedColumn intentionText = GeneratedColumn( + 'intention_text', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _plannedMinutesMeta = const VerificationMeta( 'plannedMinutes', ); @@ -414,6 +1063,9 @@ class $FocusSessionRowsTable extends FocusSessionRows List get $columns => [ id, dateKey, + actionId, + actionTitleSnapshot, + intentionText, plannedMinutes, actualSeconds, startedAt, @@ -445,6 +1097,30 @@ class $FocusSessionRowsTable extends FocusSessionRows } else if (isInserting) { context.missing(_dateKeyMeta); } + if (data.containsKey('action_id')) { + context.handle( + _actionIdMeta, + actionId.isAcceptableOrUnknown(data['action_id']!, _actionIdMeta), + ); + } + if (data.containsKey('action_title_snapshot')) { + context.handle( + _actionTitleSnapshotMeta, + actionTitleSnapshot.isAcceptableOrUnknown( + data['action_title_snapshot']!, + _actionTitleSnapshotMeta, + ), + ); + } + if (data.containsKey('intention_text')) { + context.handle( + _intentionTextMeta, + intentionText.isAcceptableOrUnknown( + data['intention_text']!, + _intentionTextMeta, + ), + ); + } if (data.containsKey('planned_minutes')) { context.handle( _plannedMinutesMeta, @@ -510,6 +1186,18 @@ class $FocusSessionRowsTable extends FocusSessionRows DriftSqlType.string, data['${effectivePrefix}date_key'], )!, + actionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}action_id'], + ), + actionTitleSnapshot: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}action_title_snapshot'], + ), + intentionText: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}intention_text'], + ), plannedMinutes: attachedDatabase.typeMapping.read( DriftSqlType.int, @@ -547,6 +1235,9 @@ class $FocusSessionRowsTable extends FocusSessionRows class FocusSessionRow extends DataClass implements Insertable { final String id; final String dateKey; + final String? actionId; + final String? actionTitleSnapshot; + final String? intentionText; final int plannedMinutes; final int actualSeconds; final DateTime startedAt; @@ -555,6 +1246,9 @@ class FocusSessionRow extends DataClass implements Insertable { const FocusSessionRow({ required this.id, required this.dateKey, + this.actionId, + this.actionTitleSnapshot, + this.intentionText, required this.plannedMinutes, required this.actualSeconds, required this.startedAt, @@ -566,6 +1260,15 @@ class FocusSessionRow extends DataClass implements Insertable { final map = {}; map['id'] = Variable(id); map['date_key'] = Variable(dateKey); + if (!nullToAbsent || actionId != null) { + map['action_id'] = Variable(actionId); + } + if (!nullToAbsent || actionTitleSnapshot != null) { + map['action_title_snapshot'] = Variable(actionTitleSnapshot); + } + if (!nullToAbsent || intentionText != null) { + map['intention_text'] = Variable(intentionText); + } map['planned_minutes'] = Variable(plannedMinutes); map['actual_seconds'] = Variable(actualSeconds); map['started_at'] = Variable(startedAt); @@ -578,6 +1281,18 @@ class FocusSessionRow extends DataClass implements Insertable { return FocusSessionRowsCompanion( id: Value(id), dateKey: Value(dateKey), + actionId: + actionId == null && nullToAbsent + ? const Value.absent() + : Value(actionId), + actionTitleSnapshot: + actionTitleSnapshot == null && nullToAbsent + ? const Value.absent() + : Value(actionTitleSnapshot), + intentionText: + intentionText == null && nullToAbsent + ? const Value.absent() + : Value(intentionText), plannedMinutes: Value(plannedMinutes), actualSeconds: Value(actualSeconds), startedAt: Value(startedAt), @@ -594,6 +1309,11 @@ class FocusSessionRow extends DataClass implements Insertable { return FocusSessionRow( id: serializer.fromJson(json['id']), dateKey: serializer.fromJson(json['dateKey']), + actionId: serializer.fromJson(json['actionId']), + actionTitleSnapshot: serializer.fromJson( + json['actionTitleSnapshot'], + ), + intentionText: serializer.fromJson(json['intentionText']), plannedMinutes: serializer.fromJson(json['plannedMinutes']), actualSeconds: serializer.fromJson(json['actualSeconds']), startedAt: serializer.fromJson(json['startedAt']), @@ -607,6 +1327,9 @@ class FocusSessionRow extends DataClass implements Insertable { return { 'id': serializer.toJson(id), 'dateKey': serializer.toJson(dateKey), + 'actionId': serializer.toJson(actionId), + 'actionTitleSnapshot': serializer.toJson(actionTitleSnapshot), + 'intentionText': serializer.toJson(intentionText), 'plannedMinutes': serializer.toJson(plannedMinutes), 'actualSeconds': serializer.toJson(actualSeconds), 'startedAt': serializer.toJson(startedAt), @@ -618,6 +1341,9 @@ class FocusSessionRow extends DataClass implements Insertable { FocusSessionRow copyWith({ String? id, String? dateKey, + Value actionId = const Value.absent(), + Value actionTitleSnapshot = const Value.absent(), + Value intentionText = const Value.absent(), int? plannedMinutes, int? actualSeconds, DateTime? startedAt, @@ -626,6 +1352,13 @@ class FocusSessionRow extends DataClass implements Insertable { }) => FocusSessionRow( id: id ?? this.id, dateKey: dateKey ?? this.dateKey, + actionId: actionId.present ? actionId.value : this.actionId, + actionTitleSnapshot: + actionTitleSnapshot.present + ? actionTitleSnapshot.value + : this.actionTitleSnapshot, + intentionText: + intentionText.present ? intentionText.value : this.intentionText, plannedMinutes: plannedMinutes ?? this.plannedMinutes, actualSeconds: actualSeconds ?? this.actualSeconds, startedAt: startedAt ?? this.startedAt, @@ -636,6 +1369,15 @@ class FocusSessionRow extends DataClass implements Insertable { return FocusSessionRow( id: data.id.present ? data.id.value : this.id, dateKey: data.dateKey.present ? data.dateKey.value : this.dateKey, + actionId: data.actionId.present ? data.actionId.value : this.actionId, + actionTitleSnapshot: + data.actionTitleSnapshot.present + ? data.actionTitleSnapshot.value + : this.actionTitleSnapshot, + intentionText: + data.intentionText.present + ? data.intentionText.value + : this.intentionText, plannedMinutes: data.plannedMinutes.present ? data.plannedMinutes.value @@ -655,6 +1397,9 @@ class FocusSessionRow extends DataClass implements Insertable { return (StringBuffer('FocusSessionRow(') ..write('id: $id, ') ..write('dateKey: $dateKey, ') + ..write('actionId: $actionId, ') + ..write('actionTitleSnapshot: $actionTitleSnapshot, ') + ..write('intentionText: $intentionText, ') ..write('plannedMinutes: $plannedMinutes, ') ..write('actualSeconds: $actualSeconds, ') ..write('startedAt: $startedAt, ') @@ -668,6 +1413,9 @@ class FocusSessionRow extends DataClass implements Insertable { int get hashCode => Object.hash( id, dateKey, + actionId, + actionTitleSnapshot, + intentionText, plannedMinutes, actualSeconds, startedAt, @@ -680,6 +1428,9 @@ class FocusSessionRow extends DataClass implements Insertable { (other is FocusSessionRow && other.id == this.id && other.dateKey == this.dateKey && + other.actionId == this.actionId && + other.actionTitleSnapshot == this.actionTitleSnapshot && + other.intentionText == this.intentionText && other.plannedMinutes == this.plannedMinutes && other.actualSeconds == this.actualSeconds && other.startedAt == this.startedAt && @@ -690,6 +1441,9 @@ class FocusSessionRow extends DataClass implements Insertable { class FocusSessionRowsCompanion extends UpdateCompanion { final Value id; final Value dateKey; + final Value actionId; + final Value actionTitleSnapshot; + final Value intentionText; final Value plannedMinutes; final Value actualSeconds; final Value startedAt; @@ -699,6 +1453,9 @@ class FocusSessionRowsCompanion extends UpdateCompanion { const FocusSessionRowsCompanion({ this.id = const Value.absent(), this.dateKey = const Value.absent(), + this.actionId = const Value.absent(), + this.actionTitleSnapshot = const Value.absent(), + this.intentionText = const Value.absent(), this.plannedMinutes = const Value.absent(), this.actualSeconds = const Value.absent(), this.startedAt = const Value.absent(), @@ -709,6 +1466,9 @@ class FocusSessionRowsCompanion extends UpdateCompanion { FocusSessionRowsCompanion.insert({ required String id, required String dateKey, + this.actionId = const Value.absent(), + this.actionTitleSnapshot = const Value.absent(), + this.intentionText = const Value.absent(), required int plannedMinutes, required int actualSeconds, required DateTime startedAt, @@ -725,6 +1485,9 @@ class FocusSessionRowsCompanion extends UpdateCompanion { static Insertable custom({ Expression? id, Expression? dateKey, + Expression? actionId, + Expression? actionTitleSnapshot, + Expression? intentionText, Expression? plannedMinutes, Expression? actualSeconds, Expression? startedAt, @@ -735,6 +1498,10 @@ class FocusSessionRowsCompanion extends UpdateCompanion { return RawValuesInsertable({ if (id != null) 'id': id, if (dateKey != null) 'date_key': dateKey, + if (actionId != null) 'action_id': actionId, + if (actionTitleSnapshot != null) + 'action_title_snapshot': actionTitleSnapshot, + if (intentionText != null) 'intention_text': intentionText, if (plannedMinutes != null) 'planned_minutes': plannedMinutes, if (actualSeconds != null) 'actual_seconds': actualSeconds, if (startedAt != null) 'started_at': startedAt, @@ -747,6 +1514,9 @@ class FocusSessionRowsCompanion extends UpdateCompanion { FocusSessionRowsCompanion copyWith({ Value? id, Value? dateKey, + Value? actionId, + Value? actionTitleSnapshot, + Value? intentionText, Value? plannedMinutes, Value? actualSeconds, Value? startedAt, @@ -757,6 +1527,9 @@ class FocusSessionRowsCompanion extends UpdateCompanion { return FocusSessionRowsCompanion( id: id ?? this.id, dateKey: dateKey ?? this.dateKey, + actionId: actionId ?? this.actionId, + actionTitleSnapshot: actionTitleSnapshot ?? this.actionTitleSnapshot, + intentionText: intentionText ?? this.intentionText, plannedMinutes: plannedMinutes ?? this.plannedMinutes, actualSeconds: actualSeconds ?? this.actualSeconds, startedAt: startedAt ?? this.startedAt, @@ -775,6 +1548,17 @@ class FocusSessionRowsCompanion extends UpdateCompanion { if (dateKey.present) { map['date_key'] = Variable(dateKey.value); } + if (actionId.present) { + map['action_id'] = Variable(actionId.value); + } + if (actionTitleSnapshot.present) { + map['action_title_snapshot'] = Variable( + actionTitleSnapshot.value, + ); + } + if (intentionText.present) { + map['intention_text'] = Variable(intentionText.value); + } if (plannedMinutes.present) { map['planned_minutes'] = Variable(plannedMinutes.value); } @@ -801,6 +1585,9 @@ class FocusSessionRowsCompanion extends UpdateCompanion { return (StringBuffer('FocusSessionRowsCompanion(') ..write('id: $id, ') ..write('dateKey: $dateKey, ') + ..write('actionId: $actionId, ') + ..write('actionTitleSnapshot: $actionTitleSnapshot, ') + ..write('intentionText: $intentionText, ') ..write('plannedMinutes: $plannedMinutes, ') ..write('actualSeconds: $actualSeconds, ') ..write('startedAt: $startedAt, ') @@ -812,6 +1599,851 @@ class FocusSessionRowsCompanion extends UpdateCompanion { } } +class $ProgressNoteRowsTable extends ProgressNoteRows + with TableInfo<$ProgressNoteRowsTable, ProgressNoteRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ProgressNoteRowsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _dateKeyMeta = const VerificationMeta( + 'dateKey', + ); + @override + late final GeneratedColumn dateKey = GeneratedColumn( + 'date_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _actionIdMeta = const VerificationMeta( + 'actionId', + ); + @override + late final GeneratedColumn actionId = GeneratedColumn( + 'action_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _sessionIdMeta = const VerificationMeta( + 'sessionId', + ); + @override + late final GeneratedColumn sessionId = GeneratedColumn( + 'session_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _noteMeta = const VerificationMeta('note'); + @override + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _outcomeMeta = const VerificationMeta( + 'outcome', + ); + @override + late final GeneratedColumn outcome = GeneratedColumn( + 'outcome', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _labelsJsonMeta = const VerificationMeta( + 'labelsJson', + ); + @override + late final GeneratedColumn labelsJson = GeneratedColumn( + 'labels_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + dateKey, + actionId, + sessionId, + note, + outcome, + labelsJson, + createdAt, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'progress_notes'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('date_key')) { + context.handle( + _dateKeyMeta, + dateKey.isAcceptableOrUnknown(data['date_key']!, _dateKeyMeta), + ); + } else if (isInserting) { + context.missing(_dateKeyMeta); + } + if (data.containsKey('action_id')) { + context.handle( + _actionIdMeta, + actionId.isAcceptableOrUnknown(data['action_id']!, _actionIdMeta), + ); + } + if (data.containsKey('session_id')) { + context.handle( + _sessionIdMeta, + sessionId.isAcceptableOrUnknown(data['session_id']!, _sessionIdMeta), + ); + } + if (data.containsKey('note')) { + context.handle( + _noteMeta, + note.isAcceptableOrUnknown(data['note']!, _noteMeta), + ); + } else if (isInserting) { + context.missing(_noteMeta); + } + if (data.containsKey('outcome')) { + context.handle( + _outcomeMeta, + outcome.isAcceptableOrUnknown(data['outcome']!, _outcomeMeta), + ); + } + if (data.containsKey('labels_json')) { + context.handle( + _labelsJsonMeta, + labelsJson.isAcceptableOrUnknown(data['labels_json']!, _labelsJsonMeta), + ); + } else if (isInserting) { + context.missing(_labelsJsonMeta); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ProgressNoteRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ProgressNoteRow( + id: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + dateKey: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}date_key'], + )!, + actionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}action_id'], + ), + sessionId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}session_id'], + ), + note: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}note'], + )!, + outcome: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}outcome'], + ), + labelsJson: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}labels_json'], + )!, + createdAt: + attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: + attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ); + } + + @override + $ProgressNoteRowsTable createAlias(String alias) { + return $ProgressNoteRowsTable(attachedDatabase, alias); + } +} + +class ProgressNoteRow extends DataClass implements Insertable { + final String id; + final String dateKey; + final String? actionId; + final String? sessionId; + final String note; + final String? outcome; + final String labelsJson; + final DateTime createdAt; + final DateTime updatedAt; + const ProgressNoteRow({ + required this.id, + required this.dateKey, + this.actionId, + this.sessionId, + required this.note, + this.outcome, + required this.labelsJson, + required this.createdAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['date_key'] = Variable(dateKey); + if (!nullToAbsent || actionId != null) { + map['action_id'] = Variable(actionId); + } + if (!nullToAbsent || sessionId != null) { + map['session_id'] = Variable(sessionId); + } + map['note'] = Variable(note); + if (!nullToAbsent || outcome != null) { + map['outcome'] = Variable(outcome); + } + map['labels_json'] = Variable(labelsJson); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + return map; + } + + ProgressNoteRowsCompanion toCompanion(bool nullToAbsent) { + return ProgressNoteRowsCompanion( + id: Value(id), + dateKey: Value(dateKey), + actionId: + actionId == null && nullToAbsent + ? const Value.absent() + : Value(actionId), + sessionId: + sessionId == null && nullToAbsent + ? const Value.absent() + : Value(sessionId), + note: Value(note), + outcome: + outcome == null && nullToAbsent + ? const Value.absent() + : Value(outcome), + labelsJson: Value(labelsJson), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + ); + } + + factory ProgressNoteRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ProgressNoteRow( + id: serializer.fromJson(json['id']), + dateKey: serializer.fromJson(json['dateKey']), + actionId: serializer.fromJson(json['actionId']), + sessionId: serializer.fromJson(json['sessionId']), + note: serializer.fromJson(json['note']), + outcome: serializer.fromJson(json['outcome']), + labelsJson: serializer.fromJson(json['labelsJson']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'dateKey': serializer.toJson(dateKey), + 'actionId': serializer.toJson(actionId), + 'sessionId': serializer.toJson(sessionId), + 'note': serializer.toJson(note), + 'outcome': serializer.toJson(outcome), + 'labelsJson': serializer.toJson(labelsJson), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + ProgressNoteRow copyWith({ + String? id, + String? dateKey, + Value actionId = const Value.absent(), + Value sessionId = const Value.absent(), + String? note, + Value outcome = const Value.absent(), + String? labelsJson, + DateTime? createdAt, + DateTime? updatedAt, + }) => ProgressNoteRow( + id: id ?? this.id, + dateKey: dateKey ?? this.dateKey, + actionId: actionId.present ? actionId.value : this.actionId, + sessionId: sessionId.present ? sessionId.value : this.sessionId, + note: note ?? this.note, + outcome: outcome.present ? outcome.value : this.outcome, + labelsJson: labelsJson ?? this.labelsJson, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + ProgressNoteRow copyWithCompanion(ProgressNoteRowsCompanion data) { + return ProgressNoteRow( + id: data.id.present ? data.id.value : this.id, + dateKey: data.dateKey.present ? data.dateKey.value : this.dateKey, + actionId: data.actionId.present ? data.actionId.value : this.actionId, + sessionId: data.sessionId.present ? data.sessionId.value : this.sessionId, + note: data.note.present ? data.note.value : this.note, + outcome: data.outcome.present ? data.outcome.value : this.outcome, + labelsJson: + data.labelsJson.present ? data.labelsJson.value : this.labelsJson, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('ProgressNoteRow(') + ..write('id: $id, ') + ..write('dateKey: $dateKey, ') + ..write('actionId: $actionId, ') + ..write('sessionId: $sessionId, ') + ..write('note: $note, ') + ..write('outcome: $outcome, ') + ..write('labelsJson: $labelsJson, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + dateKey, + actionId, + sessionId, + note, + outcome, + labelsJson, + createdAt, + updatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ProgressNoteRow && + other.id == this.id && + other.dateKey == this.dateKey && + other.actionId == this.actionId && + other.sessionId == this.sessionId && + other.note == this.note && + other.outcome == this.outcome && + other.labelsJson == this.labelsJson && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class ProgressNoteRowsCompanion extends UpdateCompanion { + final Value id; + final Value dateKey; + final Value actionId; + final Value sessionId; + final Value note; + final Value outcome; + final Value labelsJson; + final Value createdAt; + final Value updatedAt; + final Value rowid; + const ProgressNoteRowsCompanion({ + this.id = const Value.absent(), + this.dateKey = const Value.absent(), + this.actionId = const Value.absent(), + this.sessionId = const Value.absent(), + this.note = const Value.absent(), + this.outcome = const Value.absent(), + this.labelsJson = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ProgressNoteRowsCompanion.insert({ + required String id, + required String dateKey, + this.actionId = const Value.absent(), + this.sessionId = const Value.absent(), + required String note, + this.outcome = const Value.absent(), + required String labelsJson, + required DateTime createdAt, + required DateTime updatedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + dateKey = Value(dateKey), + note = Value(note), + labelsJson = Value(labelsJson), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? dateKey, + Expression? actionId, + Expression? sessionId, + Expression? note, + Expression? outcome, + Expression? labelsJson, + Expression? createdAt, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (dateKey != null) 'date_key': dateKey, + if (actionId != null) 'action_id': actionId, + if (sessionId != null) 'session_id': sessionId, + if (note != null) 'note': note, + if (outcome != null) 'outcome': outcome, + if (labelsJson != null) 'labels_json': labelsJson, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ProgressNoteRowsCompanion copyWith({ + Value? id, + Value? dateKey, + Value? actionId, + Value? sessionId, + Value? note, + Value? outcome, + Value? labelsJson, + Value? createdAt, + Value? updatedAt, + Value? rowid, + }) { + return ProgressNoteRowsCompanion( + id: id ?? this.id, + dateKey: dateKey ?? this.dateKey, + actionId: actionId ?? this.actionId, + sessionId: sessionId ?? this.sessionId, + note: note ?? this.note, + outcome: outcome ?? this.outcome, + labelsJson: labelsJson ?? this.labelsJson, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (dateKey.present) { + map['date_key'] = Variable(dateKey.value); + } + if (actionId.present) { + map['action_id'] = Variable(actionId.value); + } + if (sessionId.present) { + map['session_id'] = Variable(sessionId.value); + } + if (note.present) { + map['note'] = Variable(note.value); + } + if (outcome.present) { + map['outcome'] = Variable(outcome.value); + } + if (labelsJson.present) { + map['labels_json'] = Variable(labelsJson.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ProgressNoteRowsCompanion(') + ..write('id: $id, ') + ..write('dateKey: $dateKey, ') + ..write('actionId: $actionId, ') + ..write('sessionId: $sessionId, ') + ..write('note: $note, ') + ..write('outcome: $outcome, ') + ..write('labelsJson: $labelsJson, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $RecentLabelRowsTable extends RecentLabelRows + with TableInfo<$RecentLabelRowsTable, RecentLabelRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $RecentLabelRowsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _labelMeta = const VerificationMeta('label'); + @override + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastUsedAtMeta = const VerificationMeta( + 'lastUsedAt', + ); + @override + late final GeneratedColumn lastUsedAt = GeneratedColumn( + 'last_used_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _useCountMeta = const VerificationMeta( + 'useCount', + ); + @override + late final GeneratedColumn useCount = GeneratedColumn( + 'use_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [label, lastUsedAt, useCount]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'recent_labels'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('label')) { + context.handle( + _labelMeta, + label.isAcceptableOrUnknown(data['label']!, _labelMeta), + ); + } else if (isInserting) { + context.missing(_labelMeta); + } + if (data.containsKey('last_used_at')) { + context.handle( + _lastUsedAtMeta, + lastUsedAt.isAcceptableOrUnknown( + data['last_used_at']!, + _lastUsedAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_lastUsedAtMeta); + } + if (data.containsKey('use_count')) { + context.handle( + _useCountMeta, + useCount.isAcceptableOrUnknown(data['use_count']!, _useCountMeta), + ); + } else if (isInserting) { + context.missing(_useCountMeta); + } + return context; + } + + @override + Set get $primaryKey => {label}; + @override + RecentLabelRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RecentLabelRow( + label: + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}label'], + )!, + lastUsedAt: + attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_used_at'], + )!, + useCount: + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}use_count'], + )!, + ); + } + + @override + $RecentLabelRowsTable createAlias(String alias) { + return $RecentLabelRowsTable(attachedDatabase, alias); + } +} + +class RecentLabelRow extends DataClass implements Insertable { + final String label; + final DateTime lastUsedAt; + final int useCount; + const RecentLabelRow({ + required this.label, + required this.lastUsedAt, + required this.useCount, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['label'] = Variable(label); + map['last_used_at'] = Variable(lastUsedAt); + map['use_count'] = Variable(useCount); + return map; + } + + RecentLabelRowsCompanion toCompanion(bool nullToAbsent) { + return RecentLabelRowsCompanion( + label: Value(label), + lastUsedAt: Value(lastUsedAt), + useCount: Value(useCount), + ); + } + + factory RecentLabelRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RecentLabelRow( + label: serializer.fromJson(json['label']), + lastUsedAt: serializer.fromJson(json['lastUsedAt']), + useCount: serializer.fromJson(json['useCount']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'label': serializer.toJson(label), + 'lastUsedAt': serializer.toJson(lastUsedAt), + 'useCount': serializer.toJson(useCount), + }; + } + + RecentLabelRow copyWith({ + String? label, + DateTime? lastUsedAt, + int? useCount, + }) => RecentLabelRow( + label: label ?? this.label, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, + useCount: useCount ?? this.useCount, + ); + RecentLabelRow copyWithCompanion(RecentLabelRowsCompanion data) { + return RecentLabelRow( + label: data.label.present ? data.label.value : this.label, + lastUsedAt: + data.lastUsedAt.present ? data.lastUsedAt.value : this.lastUsedAt, + useCount: data.useCount.present ? data.useCount.value : this.useCount, + ); + } + + @override + String toString() { + return (StringBuffer('RecentLabelRow(') + ..write('label: $label, ') + ..write('lastUsedAt: $lastUsedAt, ') + ..write('useCount: $useCount') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(label, lastUsedAt, useCount); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RecentLabelRow && + other.label == this.label && + other.lastUsedAt == this.lastUsedAt && + other.useCount == this.useCount); +} + +class RecentLabelRowsCompanion extends UpdateCompanion { + final Value label; + final Value lastUsedAt; + final Value useCount; + final Value rowid; + const RecentLabelRowsCompanion({ + this.label = const Value.absent(), + this.lastUsedAt = const Value.absent(), + this.useCount = const Value.absent(), + this.rowid = const Value.absent(), + }); + RecentLabelRowsCompanion.insert({ + required String label, + required DateTime lastUsedAt, + required int useCount, + this.rowid = const Value.absent(), + }) : label = Value(label), + lastUsedAt = Value(lastUsedAt), + useCount = Value(useCount); + static Insertable custom({ + Expression? label, + Expression? lastUsedAt, + Expression? useCount, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (label != null) 'label': label, + if (lastUsedAt != null) 'last_used_at': lastUsedAt, + if (useCount != null) 'use_count': useCount, + if (rowid != null) 'rowid': rowid, + }); + } + + RecentLabelRowsCompanion copyWith({ + Value? label, + Value? lastUsedAt, + Value? useCount, + Value? rowid, + }) { + return RecentLabelRowsCompanion( + label: label ?? this.label, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, + useCount: useCount ?? this.useCount, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (label.present) { + map['label'] = Variable(label.value); + } + if (lastUsedAt.present) { + map['last_used_at'] = Variable(lastUsedAt.value); + } + if (useCount.present) { + map['use_count'] = Variable(useCount.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RecentLabelRowsCompanion(') + ..write('label: $label, ') + ..write('lastUsedAt: $lastUsedAt, ') + ..write('useCount: $useCount, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $HabitRowsTable extends HabitRows with TableInfo<$HabitRowsTable, HabitRow> { @override @@ -2124,9 +3756,18 @@ abstract class _$FocusSeedDatabase extends GeneratedDatabase { $FocusSeedDatabaseManager get managers => $FocusSeedDatabaseManager(this); late final $DailyIntentionRowsTable dailyIntentionRows = $DailyIntentionRowsTable(this); + late final $DailyActionRowsTable dailyActionRows = $DailyActionRowsTable( + this, + ); late final $FocusSessionRowsTable focusSessionRows = $FocusSessionRowsTable( this, ); + late final $ProgressNoteRowsTable progressNoteRows = $ProgressNoteRowsTable( + this, + ); + late final $RecentLabelRowsTable recentLabelRows = $RecentLabelRowsTable( + this, + ); late final $HabitRowsTable habitRows = $HabitRowsTable(this); late final $HabitLogRowsTable habitLogRows = $HabitLogRowsTable(this); late final $ReflectionRowsTable reflectionRows = $ReflectionRowsTable(this); @@ -2137,7 +3778,10 @@ abstract class _$FocusSeedDatabase extends GeneratedDatabase { @override List get allSchemaEntities => [ dailyIntentionRows, + dailyActionRows, focusSessionRows, + progressNoteRows, + recentLabelRows, habitRows, habitLogRows, reflectionRows, @@ -2354,10 +3998,338 @@ typedef $$DailyIntentionRowsTableProcessedTableManager = DailyIntentionRow, PrefetchHooks Function() >; +typedef $$DailyActionRowsTableCreateCompanionBuilder = + DailyActionRowsCompanion Function({ + required String id, + required String dateKey, + required String title, + required String status, + required int orderIndex, + required String labelsJson, + required DateTime createdAt, + required DateTime updatedAt, + Value archivedAt, + required String source, + Value rowid, + }); +typedef $$DailyActionRowsTableUpdateCompanionBuilder = + DailyActionRowsCompanion Function({ + Value id, + Value dateKey, + Value title, + Value status, + Value orderIndex, + Value labelsJson, + Value createdAt, + Value updatedAt, + Value archivedAt, + Value source, + Value rowid, + }); + +class $$DailyActionRowsTableFilterComposer + extends Composer<_$FocusSeedDatabase, $DailyActionRowsTable> { + $$DailyActionRowsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dateKey => $composableBuilder( + column: $table.dateKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get orderIndex => $composableBuilder( + column: $table.orderIndex, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get labelsJson => $composableBuilder( + column: $table.labelsJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get archivedAt => $composableBuilder( + column: $table.archivedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnFilters(column), + ); +} + +class $$DailyActionRowsTableOrderingComposer + extends Composer<_$FocusSeedDatabase, $DailyActionRowsTable> { + $$DailyActionRowsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dateKey => $composableBuilder( + column: $table.dateKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get orderIndex => $composableBuilder( + column: $table.orderIndex, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get labelsJson => $composableBuilder( + column: $table.labelsJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get archivedAt => $composableBuilder( + column: $table.archivedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get source => $composableBuilder( + column: $table.source, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$DailyActionRowsTableAnnotationComposer + extends Composer<_$FocusSeedDatabase, $DailyActionRowsTable> { + $$DailyActionRowsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get dateKey => + $composableBuilder(column: $table.dateKey, builder: (column) => column); + + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get orderIndex => $composableBuilder( + column: $table.orderIndex, + builder: (column) => column, + ); + + GeneratedColumn get labelsJson => $composableBuilder( + column: $table.labelsJson, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumn get archivedAt => $composableBuilder( + column: $table.archivedAt, + builder: (column) => column, + ); + + GeneratedColumn get source => + $composableBuilder(column: $table.source, builder: (column) => column); +} + +class $$DailyActionRowsTableTableManager + extends + RootTableManager< + _$FocusSeedDatabase, + $DailyActionRowsTable, + DailyActionRow, + $$DailyActionRowsTableFilterComposer, + $$DailyActionRowsTableOrderingComposer, + $$DailyActionRowsTableAnnotationComposer, + $$DailyActionRowsTableCreateCompanionBuilder, + $$DailyActionRowsTableUpdateCompanionBuilder, + ( + DailyActionRow, + BaseReferences< + _$FocusSeedDatabase, + $DailyActionRowsTable, + DailyActionRow + >, + ), + DailyActionRow, + PrefetchHooks Function() + > { + $$DailyActionRowsTableTableManager( + _$FocusSeedDatabase db, + $DailyActionRowsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => + $$DailyActionRowsTableFilterComposer($db: db, $table: table), + createOrderingComposer: + () => $$DailyActionRowsTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: + () => $$DailyActionRowsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value dateKey = const Value.absent(), + Value title = const Value.absent(), + Value status = const Value.absent(), + Value orderIndex = const Value.absent(), + Value labelsJson = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value archivedAt = const Value.absent(), + Value source = const Value.absent(), + Value rowid = const Value.absent(), + }) => DailyActionRowsCompanion( + id: id, + dateKey: dateKey, + title: title, + status: status, + orderIndex: orderIndex, + labelsJson: labelsJson, + createdAt: createdAt, + updatedAt: updatedAt, + archivedAt: archivedAt, + source: source, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String dateKey, + required String title, + required String status, + required int orderIndex, + required String labelsJson, + required DateTime createdAt, + required DateTime updatedAt, + Value archivedAt = const Value.absent(), + required String source, + Value rowid = const Value.absent(), + }) => DailyActionRowsCompanion.insert( + id: id, + dateKey: dateKey, + title: title, + status: status, + orderIndex: orderIndex, + labelsJson: labelsJson, + createdAt: createdAt, + updatedAt: updatedAt, + archivedAt: archivedAt, + source: source, + rowid: rowid, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + BaseReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$DailyActionRowsTableProcessedTableManager = + ProcessedTableManager< + _$FocusSeedDatabase, + $DailyActionRowsTable, + DailyActionRow, + $$DailyActionRowsTableFilterComposer, + $$DailyActionRowsTableOrderingComposer, + $$DailyActionRowsTableAnnotationComposer, + $$DailyActionRowsTableCreateCompanionBuilder, + $$DailyActionRowsTableUpdateCompanionBuilder, + ( + DailyActionRow, + BaseReferences< + _$FocusSeedDatabase, + $DailyActionRowsTable, + DailyActionRow + >, + ), + DailyActionRow, + PrefetchHooks Function() + >; typedef $$FocusSessionRowsTableCreateCompanionBuilder = FocusSessionRowsCompanion Function({ required String id, required String dateKey, + Value actionId, + Value actionTitleSnapshot, + Value intentionText, required int plannedMinutes, required int actualSeconds, required DateTime startedAt, @@ -2369,6 +4341,9 @@ typedef $$FocusSessionRowsTableUpdateCompanionBuilder = FocusSessionRowsCompanion Function({ Value id, Value dateKey, + Value actionId, + Value actionTitleSnapshot, + Value intentionText, Value plannedMinutes, Value actualSeconds, Value startedAt, @@ -2396,6 +4371,21 @@ class $$FocusSessionRowsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get actionId => $composableBuilder( + column: $table.actionId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get actionTitleSnapshot => $composableBuilder( + column: $table.actionTitleSnapshot, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get intentionText => $composableBuilder( + column: $table.intentionText, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get plannedMinutes => $composableBuilder( column: $table.plannedMinutes, builder: (column) => ColumnFilters(column), @@ -2441,6 +4431,21 @@ class $$FocusSessionRowsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get actionId => $composableBuilder( + column: $table.actionId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get actionTitleSnapshot => $composableBuilder( + column: $table.actionTitleSnapshot, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get intentionText => $composableBuilder( + column: $table.intentionText, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get plannedMinutes => $composableBuilder( column: $table.plannedMinutes, builder: (column) => ColumnOrderings(column), @@ -2482,6 +4487,19 @@ class $$FocusSessionRowsTableAnnotationComposer GeneratedColumn get dateKey => $composableBuilder(column: $table.dateKey, builder: (column) => column); + GeneratedColumn get actionId => + $composableBuilder(column: $table.actionId, builder: (column) => column); + + GeneratedColumn get actionTitleSnapshot => $composableBuilder( + column: $table.actionTitleSnapshot, + builder: (column) => column, + ); + + GeneratedColumn get intentionText => $composableBuilder( + column: $table.intentionText, + builder: (column) => column, + ); + GeneratedColumn get plannedMinutes => $composableBuilder( column: $table.plannedMinutes, builder: (column) => column, @@ -2548,6 +4566,9 @@ class $$FocusSessionRowsTableTableManager ({ Value id = const Value.absent(), Value dateKey = const Value.absent(), + Value actionId = const Value.absent(), + Value actionTitleSnapshot = const Value.absent(), + Value intentionText = const Value.absent(), Value plannedMinutes = const Value.absent(), Value actualSeconds = const Value.absent(), Value startedAt = const Value.absent(), @@ -2557,6 +4578,9 @@ class $$FocusSessionRowsTableTableManager }) => FocusSessionRowsCompanion( id: id, dateKey: dateKey, + actionId: actionId, + actionTitleSnapshot: actionTitleSnapshot, + intentionText: intentionText, plannedMinutes: plannedMinutes, actualSeconds: actualSeconds, startedAt: startedAt, @@ -2568,6 +4592,9 @@ class $$FocusSessionRowsTableTableManager ({ required String id, required String dateKey, + Value actionId = const Value.absent(), + Value actionTitleSnapshot = const Value.absent(), + Value intentionText = const Value.absent(), required int plannedMinutes, required int actualSeconds, required DateTime startedAt, @@ -2577,6 +4604,9 @@ class $$FocusSessionRowsTableTableManager }) => FocusSessionRowsCompanion.insert( id: id, dateKey: dateKey, + actionId: actionId, + actionTitleSnapshot: actionTitleSnapshot, + intentionText: intentionText, plannedMinutes: plannedMinutes, actualSeconds: actualSeconds, startedAt: startedAt, @@ -2620,6 +4650,496 @@ typedef $$FocusSessionRowsTableProcessedTableManager = FocusSessionRow, PrefetchHooks Function() >; +typedef $$ProgressNoteRowsTableCreateCompanionBuilder = + ProgressNoteRowsCompanion Function({ + required String id, + required String dateKey, + Value actionId, + Value sessionId, + required String note, + Value outcome, + required String labelsJson, + required DateTime createdAt, + required DateTime updatedAt, + Value rowid, + }); +typedef $$ProgressNoteRowsTableUpdateCompanionBuilder = + ProgressNoteRowsCompanion Function({ + Value id, + Value dateKey, + Value actionId, + Value sessionId, + Value note, + Value outcome, + Value labelsJson, + Value createdAt, + Value updatedAt, + Value rowid, + }); + +class $$ProgressNoteRowsTableFilterComposer + extends Composer<_$FocusSeedDatabase, $ProgressNoteRowsTable> { + $$ProgressNoteRowsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dateKey => $composableBuilder( + column: $table.dateKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get actionId => $composableBuilder( + column: $table.actionId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sessionId => $composableBuilder( + column: $table.sessionId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get outcome => $composableBuilder( + column: $table.outcome, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get labelsJson => $composableBuilder( + column: $table.labelsJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ProgressNoteRowsTableOrderingComposer + extends Composer<_$FocusSeedDatabase, $ProgressNoteRowsTable> { + $$ProgressNoteRowsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dateKey => $composableBuilder( + column: $table.dateKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get actionId => $composableBuilder( + column: $table.actionId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sessionId => $composableBuilder( + column: $table.sessionId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get note => $composableBuilder( + column: $table.note, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get outcome => $composableBuilder( + column: $table.outcome, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get labelsJson => $composableBuilder( + column: $table.labelsJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ProgressNoteRowsTableAnnotationComposer + extends Composer<_$FocusSeedDatabase, $ProgressNoteRowsTable> { + $$ProgressNoteRowsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get dateKey => + $composableBuilder(column: $table.dateKey, builder: (column) => column); + + GeneratedColumn get actionId => + $composableBuilder(column: $table.actionId, builder: (column) => column); + + GeneratedColumn get sessionId => + $composableBuilder(column: $table.sessionId, builder: (column) => column); + + GeneratedColumn get note => + $composableBuilder(column: $table.note, builder: (column) => column); + + GeneratedColumn get outcome => + $composableBuilder(column: $table.outcome, builder: (column) => column); + + GeneratedColumn get labelsJson => $composableBuilder( + column: $table.labelsJson, + builder: (column) => column, + ); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); +} + +class $$ProgressNoteRowsTableTableManager + extends + RootTableManager< + _$FocusSeedDatabase, + $ProgressNoteRowsTable, + ProgressNoteRow, + $$ProgressNoteRowsTableFilterComposer, + $$ProgressNoteRowsTableOrderingComposer, + $$ProgressNoteRowsTableAnnotationComposer, + $$ProgressNoteRowsTableCreateCompanionBuilder, + $$ProgressNoteRowsTableUpdateCompanionBuilder, + ( + ProgressNoteRow, + BaseReferences< + _$FocusSeedDatabase, + $ProgressNoteRowsTable, + ProgressNoteRow + >, + ), + ProgressNoteRow, + PrefetchHooks Function() + > { + $$ProgressNoteRowsTableTableManager( + _$FocusSeedDatabase db, + $ProgressNoteRowsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => + $$ProgressNoteRowsTableFilterComposer($db: db, $table: table), + createOrderingComposer: + () => $$ProgressNoteRowsTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: + () => $$ProgressNoteRowsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value dateKey = const Value.absent(), + Value actionId = const Value.absent(), + Value sessionId = const Value.absent(), + Value note = const Value.absent(), + Value outcome = const Value.absent(), + Value labelsJson = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => ProgressNoteRowsCompanion( + id: id, + dateKey: dateKey, + actionId: actionId, + sessionId: sessionId, + note: note, + outcome: outcome, + labelsJson: labelsJson, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String dateKey, + Value actionId = const Value.absent(), + Value sessionId = const Value.absent(), + required String note, + Value outcome = const Value.absent(), + required String labelsJson, + required DateTime createdAt, + required DateTime updatedAt, + Value rowid = const Value.absent(), + }) => ProgressNoteRowsCompanion.insert( + id: id, + dateKey: dateKey, + actionId: actionId, + sessionId: sessionId, + note: note, + outcome: outcome, + labelsJson: labelsJson, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + BaseReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ProgressNoteRowsTableProcessedTableManager = + ProcessedTableManager< + _$FocusSeedDatabase, + $ProgressNoteRowsTable, + ProgressNoteRow, + $$ProgressNoteRowsTableFilterComposer, + $$ProgressNoteRowsTableOrderingComposer, + $$ProgressNoteRowsTableAnnotationComposer, + $$ProgressNoteRowsTableCreateCompanionBuilder, + $$ProgressNoteRowsTableUpdateCompanionBuilder, + ( + ProgressNoteRow, + BaseReferences< + _$FocusSeedDatabase, + $ProgressNoteRowsTable, + ProgressNoteRow + >, + ), + ProgressNoteRow, + PrefetchHooks Function() + >; +typedef $$RecentLabelRowsTableCreateCompanionBuilder = + RecentLabelRowsCompanion Function({ + required String label, + required DateTime lastUsedAt, + required int useCount, + Value rowid, + }); +typedef $$RecentLabelRowsTableUpdateCompanionBuilder = + RecentLabelRowsCompanion Function({ + Value label, + Value lastUsedAt, + Value useCount, + Value rowid, + }); + +class $$RecentLabelRowsTableFilterComposer + extends Composer<_$FocusSeedDatabase, $RecentLabelRowsTable> { + $$RecentLabelRowsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get label => $composableBuilder( + column: $table.label, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get useCount => $composableBuilder( + column: $table.useCount, + builder: (column) => ColumnFilters(column), + ); +} + +class $$RecentLabelRowsTableOrderingComposer + extends Composer<_$FocusSeedDatabase, $RecentLabelRowsTable> { + $$RecentLabelRowsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get label => $composableBuilder( + column: $table.label, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get useCount => $composableBuilder( + column: $table.useCount, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$RecentLabelRowsTableAnnotationComposer + extends Composer<_$FocusSeedDatabase, $RecentLabelRowsTable> { + $$RecentLabelRowsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get label => + $composableBuilder(column: $table.label, builder: (column) => column); + + GeneratedColumn get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, + builder: (column) => column, + ); + + GeneratedColumn get useCount => + $composableBuilder(column: $table.useCount, builder: (column) => column); +} + +class $$RecentLabelRowsTableTableManager + extends + RootTableManager< + _$FocusSeedDatabase, + $RecentLabelRowsTable, + RecentLabelRow, + $$RecentLabelRowsTableFilterComposer, + $$RecentLabelRowsTableOrderingComposer, + $$RecentLabelRowsTableAnnotationComposer, + $$RecentLabelRowsTableCreateCompanionBuilder, + $$RecentLabelRowsTableUpdateCompanionBuilder, + ( + RecentLabelRow, + BaseReferences< + _$FocusSeedDatabase, + $RecentLabelRowsTable, + RecentLabelRow + >, + ), + RecentLabelRow, + PrefetchHooks Function() + > { + $$RecentLabelRowsTableTableManager( + _$FocusSeedDatabase db, + $RecentLabelRowsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: + () => + $$RecentLabelRowsTableFilterComposer($db: db, $table: table), + createOrderingComposer: + () => $$RecentLabelRowsTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: + () => $$RecentLabelRowsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value label = const Value.absent(), + Value lastUsedAt = const Value.absent(), + Value useCount = const Value.absent(), + Value rowid = const Value.absent(), + }) => RecentLabelRowsCompanion( + label: label, + lastUsedAt: lastUsedAt, + useCount: useCount, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String label, + required DateTime lastUsedAt, + required int useCount, + Value rowid = const Value.absent(), + }) => RecentLabelRowsCompanion.insert( + label: label, + lastUsedAt: lastUsedAt, + useCount: useCount, + rowid: rowid, + ), + withReferenceMapper: + (p0) => + p0 + .map( + (e) => ( + e.readTable(table), + BaseReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$RecentLabelRowsTableProcessedTableManager = + ProcessedTableManager< + _$FocusSeedDatabase, + $RecentLabelRowsTable, + RecentLabelRow, + $$RecentLabelRowsTableFilterComposer, + $$RecentLabelRowsTableOrderingComposer, + $$RecentLabelRowsTableAnnotationComposer, + $$RecentLabelRowsTableCreateCompanionBuilder, + $$RecentLabelRowsTableUpdateCompanionBuilder, + ( + RecentLabelRow, + BaseReferences< + _$FocusSeedDatabase, + $RecentLabelRowsTable, + RecentLabelRow + >, + ), + RecentLabelRow, + PrefetchHooks Function() + >; typedef $$HabitRowsTableCreateCompanionBuilder = HabitRowsCompanion Function({ required String id, @@ -3433,8 +5953,14 @@ class $FocusSeedDatabaseManager { $FocusSeedDatabaseManager(this._db); $$DailyIntentionRowsTableTableManager get dailyIntentionRows => $$DailyIntentionRowsTableTableManager(_db, _db.dailyIntentionRows); + $$DailyActionRowsTableTableManager get dailyActionRows => + $$DailyActionRowsTableTableManager(_db, _db.dailyActionRows); $$FocusSessionRowsTableTableManager get focusSessionRows => $$FocusSessionRowsTableTableManager(_db, _db.focusSessionRows); + $$ProgressNoteRowsTableTableManager get progressNoteRows => + $$ProgressNoteRowsTableTableManager(_db, _db.progressNoteRows); + $$RecentLabelRowsTableTableManager get recentLabelRows => + $$RecentLabelRowsTableTableManager(_db, _db.recentLabelRows); $$HabitRowsTableTableManager get habitRows => $$HabitRowsTableTableManager(_db, _db.habitRows); $$HabitLogRowsTableTableManager get habitLogRows => diff --git a/lib/src/features/habits/habit_manager_sheet.dart b/lib/src/features/habits/habit_manager_sheet.dart index 1cf37a3..0f4b668 100644 --- a/lib/src/features/habits/habit_manager_sheet.dart +++ b/lib/src/features/habits/habit_manager_sheet.dart @@ -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 showHabitManagerSheet( @@ -21,89 +25,63 @@ Future 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), ); } } diff --git a/lib/src/features/journey/history_screen.dart b/lib/src/features/journey/history_screen.dart deleted file mode 100644 index e86db9f..0000000 --- a/lib/src/features/journey/history_screen.dart +++ /dev/null @@ -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 createState() => _HistoryScreenState(); -} - -class _HistoryScreenState extends State { - _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 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 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, - ), - ), - ], - ], - ), - ); - } -} diff --git a/lib/src/features/journey/journey_screen.dart b/lib/src/features/journey/journey_screen.dart deleted file mode 100644 index 377ad7d..0000000 --- a/lib/src/features/journey/journey_screen.dart +++ /dev/null @@ -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( - 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, - ), - ), - ], - ); - } -} diff --git a/lib/src/features/journey/widgets/growth_week_strip.dart b/lib/src/features/journey/widgets/growth_week_strip.dart deleted file mode 100644 index 3a3a84a..0000000 --- a/lib/src/features/journey/widgets/growth_week_strip.dart +++ /dev/null @@ -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), - ], - ), - ); - } -} diff --git a/lib/src/features/journey/widgets/journey_overview_panel.dart b/lib/src/features/journey/widgets/journey_overview_panel.dart deleted file mode 100644 index 84b4185..0000000 --- a/lib/src/features/journey/widgets/journey_overview_panel.dart +++ /dev/null @@ -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, - ), - ), - ), - ); - } -} diff --git a/lib/src/features/journey/widgets/journey_stats_panel.dart b/lib/src/features/journey/widgets/journey_stats_panel.dart deleted file mode 100644 index e2d12e0..0000000 --- a/lib/src/features/journey/widgets/journey_stats_panel.dart +++ /dev/null @@ -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( - 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, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/src/features/journey/widgets/journey_timeline.dart b/lib/src/features/journey/widgets/journey_timeline.dart deleted file mode 100644 index 3f3a349..0000000 --- a/lib/src/features/journey/widgets/journey_timeline.dart +++ /dev/null @@ -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; -} diff --git a/lib/src/features/me/me_screen.dart b/lib/src/features/me/me_screen.dart index 0382068..b9090bb 100644 --- a/lib/src/features/me/me_screen.dart +++ b/lib/src/features/me/me_screen.dart @@ -1,13 +1,21 @@ -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 '../../core/focus_durations.dart'; import '../../l10n/l10n.dart'; import '../../models/appearance_preference.dart'; import '../../models/language_preference.dart'; +import '../../models/record_stats.dart'; +import '../../widgets/app_bottom_sheet.dart'; +import '../../widgets/app_detail_scaffold.dart'; +import '../../widgets/app_motion.dart'; import '../../widgets/dialogs.dart'; -import '../../widgets/focus_seed_header.dart'; -import '../../widgets/seed_illustration.dart'; -import '../../widgets/surface_panel.dart'; +import '../../widgets/settings_tile.dart'; +import '../../widgets/workbench_ui.dart'; class MeScreen extends StatelessWidget { const MeScreen({ @@ -17,6 +25,9 @@ class MeScreen extends StatelessWidget { required this.language, required this.showLanguageTools, required this.onLanguageChanged, + required this.onDefaultFocusMinutesChanged, + this.embedded = false, + this.showBackButton = false, super.key, }); @@ -26,525 +37,319 @@ class MeScreen extends StatelessWidget { final LanguagePreference language; final bool showLanguageTools; final ValueChanged onLanguageChanged; + final ValueChanged onDefaultFocusMinutesChanged; + final bool embedded; + final bool showBackButton; @override Widget build(BuildContext context) { final state = controller.state!; final l10n = context.l10n; - return Column( - children: [ - FocusSeedHeader( - title: l10n.meTitle, - subtitle: l10n.meSubtitle, - isSaving: controller.isSaving, - ), - Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(20, 6, 20, 32), - children: [ - _PersonalGardenCard( - focusSessions: state.sessions.length, - habits: state.habits.length, - reflections: state.reflections.length, - ), - const SizedBox(height: 16), - _PreferenceDeck( - appearance: appearance, - onAppearanceChanged: onAppearanceChanged, - language: language, - showLanguageTools: showLanguageTools, - onLanguageChanged: onLanguageChanged, - ), - const SizedBox(height: 16), - _DataCarePanel(controller: controller), - const SizedBox(height: 18), - Text( - 'Focus Seed 1.0.2', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + final stats = RecordStats.fromState(state); + final content = SafeArea( + child: Column( + children: [ + WorkbenchTopBar( + title: l10n.meTitle, + leadingIcon: showBackButton ? Icons.arrow_back_rounded : null, + leadingTooltip: l10n.navToday, + onLeadingPressed: + showBackButton ? () => Navigator.of(context).maybePop() : null, + trailingIcon: Icons.lock_outline_rounded, + trailingTooltip: l10n.privacyTitle, + onTrailingPressed: () => _openPrivacyPage(context), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 6, 20, 148), + children: [ + _MeHero( + recordCount: stats.totalRecordCount, + activeDays: stats.activeDays, + progressNoteCount: stats.progressNoteCount, + totalFocusMinutes: stats.totalFocusMinutes, + streakDays: stats.currentStreakDays, ), - ), - ], - ), - ), - ], - ); - } -} - -class _PersonalGardenCard extends StatelessWidget { - const _PersonalGardenCard({ - required this.focusSessions, - required this.habits, - required this.reflections, - }); - - final int focusSessions; - final int habits; - final int reflections; - - @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, - borderColor: scheme.outlineVariant.withValues(alpha: 0.42), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - scheme.tertiary.withValues(alpha: 0.16), - scheme.surfaceContainerLowest, - scheme.primary.withValues(alpha: 0.14), - ], - ), - ), - child: Stack( - children: [ - const Positioned( - right: -24, - top: -24, - child: SeedIllustration( - size: 154, - tone: SeedIllustrationTone.data, - hero: true, - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 17, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 250), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.personalGardenTitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w900, + const SizedBox(height: 18), + SettingsSection( + title: l10n.preferencesTitle, + children: [ + SettingsRow( + icon: Icons.tune_rounded, + title: l10n.preferencesTitle, + subtitle: l10n.preferencesDetail, + color: FocusSeedVisuals.of(context).focus, + onTap: + () => _openPreferencesPage( + context, + controller, + appearance, + onAppearanceChanged, + language, + showLanguageTools, + onLanguageChanged, + onDefaultFocusMinutesChanged, ), - ), - const SizedBox(height: 6), - Text( - l10n.personalGardenSubtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], ), - ), - const SizedBox(height: 28), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _MetricPill( - icon: Icons.timer_outlined, - label: l10n.focusDataLabel, - value: focusSessions, - ), - _MetricPill( - icon: Icons.checklist_rounded, - label: l10n.habitsDataLabel, - value: habits, - ), - _MetricPill( - icon: Icons.edit_note_rounded, - label: l10n.notesDataLabel, - value: reflections, - ), - ], - ), - ], - ), + ], + ), + const SizedBox(height: 16), + SettingsSection( + title: l10n.privacyTitle, + children: [ + SettingsRow( + icon: Icons.lock_outline_rounded, + title: l10n.privacyTitle, + subtitle: l10n.privacySubtitle, + color: FocusSeedVisuals.of(context).privacy, + onTap: () => _openPrivacyPage(context), + ), + SettingsRow( + icon: Icons.info_outline_rounded, + title: l10n.appSettingsTitle, + subtitle: l10n.meSubtitle, + color: FocusSeedVisuals.of(context).focus, + onTap: () => _openAboutPage(context), + ), + ], + ), + const SizedBox(height: 16), + _DataSection(controller: controller), + ], ), - ], - ), - ), - ); - } -} - -class _MetricPill extends StatelessWidget { - const _MetricPill({ - required this.icon, - required this.label, - required this.value, - }); - - final IconData icon; - final String label; - final int value; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - return DecoratedBox( - decoration: BoxDecoration( - color: scheme.surfaceContainerLowest.withValues(alpha: 0.76), - borderRadius: BorderRadius.circular(99), - border: Border.all( - color: scheme.outlineVariant.withValues(alpha: 0.58), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 10, - offset: const Offset(0, 4), ), ], ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: scheme.primary, size: 17), - const SizedBox(width: 7), - TweenAnimationBuilder( - tween: Tween(end: value.toDouble()), - duration: const Duration(milliseconds: 260), - curve: Curves.easeOutCubic, - builder: - (context, animatedValue, _) => Text( - animatedValue.round().toString(), - style: theme.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w900, - ), - ), - ), - const SizedBox(width: 4), - Text( - label, - style: theme.textTheme.labelMedium?.copyWith( - color: scheme.onSurfaceVariant, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), ); + if (embedded) { + return content; + } + return Scaffold(body: content); } } -class _PreferenceDeck extends StatelessWidget { - const _PreferenceDeck({ - required this.appearance, - required this.onAppearanceChanged, - required this.language, - required this.showLanguageTools, - required this.onLanguageChanged, +class _MeHero extends StatelessWidget { + const _MeHero({ + required this.recordCount, + required this.activeDays, + required this.progressNoteCount, + required this.totalFocusMinutes, + required this.streakDays, }); - final AppearancePreference appearance; - final ValueChanged onAppearanceChanged; - final LanguagePreference language; - final bool showLanguageTools; - final ValueChanged onLanguageChanged; + final int recordCount; + final int activeDays; + final int progressNoteCount; + final int totalFocusMinutes; + final int streakDays; @override Widget build(BuildContext context) { final l10n = context.l10n; - final scheme = Theme.of(context).colorScheme; - return SurfacePanel( - padding: const EdgeInsets.fromLTRB(14, 14, 14, 15), - borderColor: scheme.outlineVariant.withValues(alpha: 0.55), + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final visuals = FocusSeedVisuals.of(context); + final metrics = [ + ( + l10n.totalActionTimeLabel, + l10n.minutesShort(totalFocusMinutes), + Icons.timer_outlined, + visuals.focus, + ), + ( + l10n.streakLabel, + l10n.daysValue(streakDays), + Icons.local_fire_department_outlined, + visuals.habit, + ), + ( + l10n.activeDaysLabel, + l10n.daysValue(activeDays), + Icons.event_available_outlined, + visuals.habit, + ), + ( + l10n.recordsCountLabel, + '$recordCount', + Icons.inventory_2_outlined, + visuals.privacy, + ), + ( + l10n.progressNotesCountLabel, + '$progressNoteCount', + Icons.edit_note_rounded, + visuals.reflection, + ), + ]; + return WorkbenchPanel( + accent: visuals.privacy, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 15), + fill: Color.alphaBlend( + visuals.focus.withValues(alpha: 0.045), + scheme.surfaceContainerLowest, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SectionHeader( - title: l10n.preferencesTitle, - detail: l10n.preferencesDetail, - ), - const SizedBox(height: 12), - _PreferenceSwitcher( - value: appearance, - onChanged: onAppearanceChanged, - choices: [ - _PreferenceChoice( - value: AppearancePreference.system, - icon: Icons.brightness_auto_outlined, - label: l10n.system, - ), - _PreferenceChoice( - value: AppearancePreference.light, - icon: Icons.light_mode_outlined, - label: l10n.light, - ), - _PreferenceChoice( - value: AppearancePreference.dark, - icon: Icons.dark_mode_outlined, - label: l10n.dark, - ), - ], - ), - if (showLanguageTools) ...[ - const SizedBox(height: 16), - Divider(color: scheme.outlineVariant.withValues(alpha: 0.62)), - const SizedBox(height: 12), - _SectionHeader(title: l10n.language, detail: l10n.developmentOnly), - const SizedBox(height: 12), - _PreferenceSwitcher( - value: language, - onChanged: onLanguageChanged, - choices: [ - _PreferenceChoice( - value: LanguagePreference.defaultLocale, - icon: Icons.tune_rounded, - label: l10n.languageDefault, - ), - _PreferenceChoice( - value: LanguagePreference.english, - icon: Icons.language_rounded, - label: l10n.languageEnglish, - ), - _PreferenceChoice( - value: LanguagePreference.chinese, - icon: Icons.translate_rounded, - label: l10n.languageChinese, - ), - ], + WorkbenchHeader( + eyebrow: l10n.localData, + title: l10n.personalGardenTitle, + subtitle: l10n.personalGardenSubtitle, + icon: Icons.data_usage_rounded, + accent: visuals.privacy, + trailing: WorkbenchPill( + label: l10n.local, + icon: Icons.lock_outline_rounded, + accent: visuals.privacy, ), - ], + ), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, constraints) { + final itemWidth = (constraints.maxWidth - 8) / 2; + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final metric in metrics) + SizedBox( + width: itemWidth, + child: WorkbenchMetricCell( + label: metric.$1, + value: metric.$2, + icon: metric.$3, + accent: metric.$4, + ), + ), + ], + ); + }, + ), ], ), ); } } -class _PreferenceChoice { - const _PreferenceChoice({ - required this.value, - required this.icon, - required this.label, - }); +class _ThemePreferenceControl extends StatelessWidget { + const _ThemePreferenceControl({required this.value, required this.onChanged}); - final T value; - final IconData icon; - final String label; -} - -class _PreferenceSwitcher extends StatelessWidget { - const _PreferenceSwitcher({ - required this.value, - required this.choices, - required this.onChanged, - }); - - final T value; - final List<_PreferenceChoice> choices; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return DecoratedBox( - decoration: BoxDecoration( - color: scheme.surfaceContainerHigh.withValues(alpha: 0.48), - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(4), - child: Row( - children: [ - for (final choice in choices) ...[ - Expanded( - child: _PreferenceChip( - selected: choice.value == value, - icon: choice.icon, - label: choice.label, - onTap: () => onChanged(choice.value), - ), - ), - if (choice != choices.last) const SizedBox(width: 4), - ], - ], - ), - ), - ); - } -} - -class _PreferenceChip extends StatelessWidget { - const _PreferenceChip({ - 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: 8, vertical: 11), - decoration: BoxDecoration( - color: - selected - ? scheme.primary.withValues(alpha: 0.15) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: - selected - ? Border.all(color: scheme.primary.withValues(alpha: 0.28)) - : null, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - color: selected ? scheme.primary : scheme.onSurfaceVariant, - size: 21, - ), - const SizedBox(height: 5), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelMedium?.copyWith( - color: selected ? scheme.onSurface : scheme.onSurfaceVariant, - fontWeight: selected ? FontWeight.w900 : FontWeight.w700, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _DataCarePanel extends StatelessWidget { - const _DataCarePanel({required this.controller}); - - final FocusSeedController controller; + final AppearancePreference value; + final ValueChanged onChanged; @override Widget build(BuildContext context) { final l10n = context.l10n; + final visuals = FocusSeedVisuals.of(context); + return _InlinePreferenceBlock( + icon: _appearanceIcon(value), + title: l10n.appearance, + subtitle: _appearanceLabel(l10n, value), + color: visuals.focus, + child: Row( + children: [ + _ThemeSegment( + value: AppearancePreference.system, + selected: value == AppearancePreference.system, + icon: Icons.auto_awesome_rounded, + label: l10n.system, + color: visuals.focus, + onChanged: onChanged, + ), + const SizedBox(width: 8), + _ThemeSegment( + value: AppearancePreference.light, + selected: value == AppearancePreference.light, + icon: Icons.light_mode_rounded, + label: l10n.light, + color: visuals.habit, + onChanged: onChanged, + ), + const SizedBox(width: 8), + _ThemeSegment( + value: AppearancePreference.dark, + selected: value == AppearancePreference.dark, + icon: Icons.dark_mode_rounded, + label: l10n.dark, + color: visuals.reflection, + onChanged: onChanged, + ), + ], + ), + ); + } +} + +class _ThemeSegment extends StatelessWidget { + const _ThemeSegment({ + required this.value, + required this.selected, + required this.icon, + required this.label, + required this.color, + required this.onChanged, + }); + + final AppearancePreference value; + final bool selected; + final IconData icon; + final String label; + final Color color; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; - return SurfacePanel( - elevated: true, - padding: EdgeInsets.zero, - borderColor: scheme.outlineVariant.withValues(alpha: 0.42), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - scheme.primary.withValues(alpha: 0.10), - scheme.surfaceContainerLowest, - scheme.error.withValues(alpha: 0.05), - ], - ), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), - child: Column( - children: [ - Row( - children: [ - DecoratedBox( - decoration: BoxDecoration( - color: scheme.primary.withValues(alpha: 0.13), - borderRadius: BorderRadius.circular(12), - ), - child: SizedBox.square( - dimension: 42, - child: Icon( - Icons.verified_user_outlined, - color: scheme.primary, - size: 22, - ), + return Expanded( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + unawaited(HapticFeedback.selectionClick()); + onChanged(value); + }, + borderRadius: BorderRadius.circular(kWorkbenchRadius), + child: AnimatedContainer( + duration: AppMotion.duration(context, AppMotion.fast), + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: + selected + ? color.withValues(alpha: 0.14) + : scheme.surfaceContainerLow.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(kWorkbenchRadius), + boxShadow: + selected + ? [ + BoxShadow( + color: color.withValues(alpha: 0.12), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: selected ? color : scheme.outline), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: + selected ? scheme.onSurface : scheme.onSurfaceVariant, + fontWeight: FontWeight.w900, ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.localData, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - ), - ), - const SizedBox(height: 2), - Text( - l10n.localDataDetail, - style: theme.textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - _DataActionTile( - icon: Icons.cleaning_services_outlined, - title: l10n.clearToday, - subtitle: l10n.clearTodaySubtitle, - onTap: - () => confirmClearToday(context, () async { - final cleared = await controller.clearToday(); - if (context.mounted && cleared) { - showSnack(context, l10n.todayDataCleared); - } - }), - ), - const SizedBox(height: 8), - _DataActionTile( - icon: Icons.privacy_tip_outlined, - title: l10n.privacyTitle, - subtitle: l10n.privacySubtitle, - onTap: () => showPrivacySheet(context), - ), - const SizedBox(height: 10), - _DangerActionTile( - title: l10n.deleteAllLocalData, - subtitle: l10n.deleteAllLocalDataSubtitle, - onTap: - () => confirmClearData(context, () async { - final cleared = await controller.clearData(); - if (context.mounted && cleared) { - showSnack(context, l10n.localDataCleared); - } - }), - ), - ], + ), + ], + ), ), ), ), @@ -552,34 +357,82 @@ class _DataCarePanel extends StatelessWidget { } } -class _DataActionTile extends StatelessWidget { - const _DataActionTile({ +class _DefaultFocusControl extends StatelessWidget { + const _DefaultFocusControl({required this.value, required this.onChanged}); + + final int value; + final Future Function(int minutes) onChanged; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final visuals = FocusSeedVisuals.of(context); + final choices = {...focusDurationPresets, value}.toList()..sort(); + return _InlinePreferenceBlock( + icon: Icons.timer_outlined, + title: l10n.defaultFocusDuration, + subtitle: l10n.minutesShort(value), + color: visuals.action, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final minutes in choices) + _DurationChoice( + minutes: minutes, + selected: value == minutes, + color: visuals.action, + onTap: () => onChanged(minutes), + ), + _CustomDurationChoice( + color: visuals.action, + onTap: () async { + final minutes = await _showCustomDurationDialog(context, value); + if (minutes != null) { + await onChanged(minutes); + } + }, + ), + ], + ), + ); + } +} + +class _InlinePreferenceBlock extends StatelessWidget { + const _InlinePreferenceBlock({ required this.icon, required this.title, required this.subtitle, - required this.onTap, + required this.color, + required this.child, }); final IconData icon; final String title; final String subtitle; - final VoidCallback onTap; + final Color color; + final Widget child; @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; - return Material( - color: scheme.surfaceContainerLow.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), - child: Row( + return Padding( + padding: const EdgeInsets.fromLTRB(14, 13, 14, 14), + child: Column( + children: [ + Row( children: [ - Icon(icon, color: scheme.primary, size: 22), + 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( @@ -587,8 +440,6 @@ class _DataActionTile extends StatelessWidget { children: [ Text( title, - maxLines: 1, - overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w900, ), @@ -605,24 +456,27 @@ class _DataActionTile extends StatelessWidget { ], ), ), - const Icon(Icons.arrow_forward_ios_rounded, size: 15), ], ), - ), + const SizedBox(height: 12), + child, + ], ), ); } } -class _DangerActionTile extends StatelessWidget { - const _DangerActionTile({ - required this.title, - required this.subtitle, +class _DurationChoice extends StatelessWidget { + const _DurationChoice({ + required this.minutes, + required this.selected, + required this.color, required this.onTap, }); - final String title; - final String subtitle; + final int minutes; + final bool selected; + final Color color; final VoidCallback onTap; @override @@ -630,47 +484,41 @@ class _DangerActionTile extends StatelessWidget { final theme = Theme.of(context); final scheme = theme.colorScheme; return Material( - color: scheme.errorContainer.withValues(alpha: 0.22), - borderRadius: BorderRadius.circular(14), + color: Colors.transparent, child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), - child: Row( - children: [ - Icon( - Icons.delete_forever_outlined, - color: scheme.error, - size: 23, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - color: scheme.error, - fontWeight: FontWeight.w900, + onTap: () { + unawaited(HapticFeedback.selectionClick()); + onTap(); + }, + borderRadius: BorderRadius.circular(kWorkbenchRadius), + child: AnimatedContainer( + duration: AppMotion.duration(context, AppMotion.fast), + height: 42, + width: 62, + alignment: Alignment.center, + decoration: BoxDecoration( + color: + selected + ? color + : scheme.surfaceContainerLow.withValues(alpha: 0.50), + borderRadius: BorderRadius.circular(kWorkbenchRadius), + boxShadow: + selected + ? [ + BoxShadow( + color: color.withValues(alpha: 0.18), + blurRadius: 12, + offset: const Offset(0, 5), ), - ), - const SizedBox(height: 2), - Text( - subtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - color: scheme.onErrorContainer, - ), - ), - ], - ), - ), - ], + ] + : null, + ), + child: Text( + '$minutes', + style: theme.textTheme.labelLarge?.copyWith( + color: selected ? scheme.onPrimary : scheme.onSurface, + fontWeight: FontWeight.w900, + ), ), ), ), @@ -678,25 +526,567 @@ class _DangerActionTile extends StatelessWidget { } } -class _SectionHeader extends StatelessWidget { - const _SectionHeader({required this.title, required this.detail}); +class _CustomDurationChoice extends StatelessWidget { + const _CustomDurationChoice({required this.color, required this.onTap}); - final String title; - final String detail; + final Color color; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Row( - children: [ - Expanded(child: Text(title, style: theme.textTheme.titleMedium)), - Text( - detail, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + final scheme = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + unawaited(HapticFeedback.selectionClick()); + onTap(); + }, + borderRadius: BorderRadius.circular(kWorkbenchRadius), + child: Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: scheme.surfaceContainerLow.withValues(alpha: 0.50), + borderRadius: BorderRadius.circular(kWorkbenchRadius), ), + child: Icon(Icons.add_rounded, color: color), + ), + ), + ); + } +} + +class _DataSection extends StatelessWidget { + const _DataSection({required this.controller}); + + final FocusSeedController controller; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final visuals = FocusSeedVisuals.of(context); + return SettingsSection( + title: l10n.localData, + children: [ + SettingsRow( + icon: Icons.cleaning_services_outlined, + title: l10n.clearToday, + subtitle: l10n.clearTodaySubtitle, + color: visuals.action, + onTap: + () => confirmClearToday(context, () async { + final cleared = await controller.clearToday(); + if (context.mounted && cleared) { + showSnack(context, l10n.todayDataCleared); + } + }), + ), + SettingsRow( + icon: Icons.delete_outline_rounded, + title: l10n.deleteAllLocalData, + subtitle: l10n.deleteAllLocalDataSubtitle, + color: Theme.of(context).colorScheme.error, + onTap: + () => confirmClearData(context, () async { + final cleared = await controller.clearData(); + if (context.mounted && cleared) { + showSnack(context, l10n.localDataCleared); + } + }), ), ], ); } } + +void _openPreferencesPage( + BuildContext context, + FocusSeedController controller, + AppearancePreference appearance, + ValueChanged onAppearanceChanged, + LanguagePreference language, + bool showLanguageTools, + ValueChanged onLanguageChanged, + ValueChanged onDefaultFocusMinutesChanged, +) { + unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: + (context) => _PreferencesPage( + controller: controller, + appearance: appearance, + onAppearanceChanged: onAppearanceChanged, + language: language, + showLanguageTools: showLanguageTools, + onLanguageChanged: onLanguageChanged, + onDefaultFocusMinutesChanged: onDefaultFocusMinutesChanged, + ), + ), + ), + ); +} + +class _PreferencesPage extends StatefulWidget { + const _PreferencesPage({ + required this.controller, + required this.appearance, + required this.onAppearanceChanged, + required this.language, + required this.showLanguageTools, + required this.onLanguageChanged, + required this.onDefaultFocusMinutesChanged, + }); + + final FocusSeedController controller; + final AppearancePreference appearance; + final ValueChanged onAppearanceChanged; + final LanguagePreference language; + final bool showLanguageTools; + final ValueChanged onLanguageChanged; + final ValueChanged onDefaultFocusMinutesChanged; + + @override + State<_PreferencesPage> createState() => _PreferencesPageState(); +} + +class _PreferencesPageState extends State<_PreferencesPage> { + late AppearancePreference _appearance; + late LanguagePreference _language; + + @override + void initState() { + super.initState(); + _appearance = widget.appearance; + _language = widget.language; + } + + Future _setDefaultFocusMinutes(int minutes) async { + final l10n = context.l10n; + final saved = await widget.controller.setDefaultFocusMinutes(minutes); + if (!mounted) { + return; + } + if (saved) { + widget.onDefaultFocusMinutesChanged(minutes); + } else { + showSnack(context, l10n.saveFailed); + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AppDetailScaffold( + title: l10n.preferencesTitle, + child: AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final state = widget.controller.state!; + return Column( + children: [ + WorkbenchPanel( + accent: FocusSeedVisuals.of(context).focus, + padding: EdgeInsets.zero, + child: _ThemePreferenceControl( + value: _appearance, + onChanged: (value) { + setState(() => _appearance = value); + widget.onAppearanceChanged(value); + }, + ), + ), + const SizedBox(height: 12), + WorkbenchPanel( + accent: FocusSeedVisuals.of(context).action, + padding: EdgeInsets.zero, + child: _DefaultFocusControl( + value: state.defaultFocusMinutes, + onChanged: _setDefaultFocusMinutes, + ), + ), + if (widget.showLanguageTools) ...[ + const SizedBox(height: 12), + WorkbenchPanel( + accent: FocusSeedVisuals.of(context).reflection, + padding: EdgeInsets.zero, + child: SettingsRow( + icon: Icons.language_rounded, + title: l10n.language, + subtitle: _languageLabel(l10n, _language), + color: FocusSeedVisuals.of(context).reflection, + onTap: + () => _openLanguagePage(context, _language, (value) { + setState(() => _language = value); + widget.onLanguageChanged(value); + }), + ), + ), + ], + ], + ); + }, + ), + ); + } +} + +Future _showCustomDurationDialog(BuildContext context, int initialValue) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => _CustomDurationSheet(initialValue: initialValue), + ); +} + +class _CustomDurationSheet extends StatefulWidget { + const _CustomDurationSheet({required this.initialValue}); + + final int initialValue; + + @override + State<_CustomDurationSheet> createState() => _CustomDurationSheetState(); +} + +class _CustomDurationSheetState extends State<_CustomDurationSheet> { + late final TextEditingController _controller; + String? _errorText; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue.toString()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final minutes = int.tryParse(_controller.text.trim()); + if (minutes == null || + minutes < minFocusDurationMinutes || + minutes > maxFocusDurationMinutes) { + setState(() => _errorText = context.l10n.customFocusDurationHint); + return; + } + Navigator.of(context).pop(minutes); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final visuals = FocusSeedVisuals.of(context); + return AppBottomSheetScaffold( + title: l10n.customFocusDuration, + icon: Icons.timer_outlined, + accent: visuals.focus, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + WorkbenchPanel( + accent: visuals.focus, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: TextField( + controller: _controller, + autofocus: true, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + labelText: l10n.minutesInputLabel, + hintText: l10n.customFocusDurationHint, + errorText: _errorText, + ), + onSubmitted: (_) => _submit(), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: FilledButton( + onPressed: _submit, + style: FilledButton.styleFrom( + backgroundColor: visuals.focus, + foregroundColor: scheme.onPrimary, + minimumSize: const Size.fromHeight(50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(kWorkbenchRadius), + ), + textStyle: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + child: Text(l10n.save), + ), + ), + ], + ), + ], + ), + ); + } +} + +void _openLanguagePage( + BuildContext context, + LanguagePreference value, + ValueChanged onChanged, +) { + unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => _LanguagePage(value: value, onChanged: onChanged), + ), + ), + ); +} + +void _openPrivacyPage(BuildContext context) { + unawaited( + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const _PrivacyPage()), + ), + ); +} + +void _openAboutPage(BuildContext context) { + unawaited( + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const _AboutPage()), + ), + ); +} + +class _LanguagePage extends StatefulWidget { + const _LanguagePage({required this.value, required this.onChanged}); + + final LanguagePreference value; + final ValueChanged onChanged; + + @override + State<_LanguagePage> createState() => _LanguagePageState(); +} + +class _LanguagePageState extends State<_LanguagePage> { + late LanguagePreference _value; + + @override + void initState() { + super.initState(); + _value = widget.value; + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final visuals = FocusSeedVisuals.of(context); + final choices = [ + ( + LanguagePreference.defaultLocale, + Icons.public_rounded, + visuals.focus, + l10n.languageDefault, + ), + ( + LanguagePreference.english, + Icons.text_fields_rounded, + visuals.action, + l10n.languageEnglish, + ), + ( + LanguagePreference.chinese, + Icons.translate_rounded, + visuals.reflection, + l10n.languageChinese, + ), + ]; + return AppDetailScaffold( + title: l10n.language, + child: Column( + children: [ + for (final choice in choices) ...[ + _ChoiceTile( + icon: choice.$2, + title: choice.$4, + selected: _value == choice.$1, + color: choice.$3, + onTap: () { + unawaited(HapticFeedback.selectionClick()); + setState(() => _value = choice.$1); + widget.onChanged(choice.$1); + }, + ), + const SizedBox(height: 10), + ], + ], + ), + ); + } +} + +class _PrivacyPage extends StatelessWidget { + const _PrivacyPage(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final visuals = FocusSeedVisuals.of(context); + return AppDetailScaffold( + title: l10n.privacyTitle, + child: WorkbenchPanel( + accent: visuals.privacy, + fill: visuals.privacy.withValues(alpha: 0.07), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.lock_outline_rounded, color: visuals.privacy, size: 30), + const SizedBox(height: 14), + Text(l10n.privacyBodyOne), + const SizedBox(height: 12), + Text(l10n.privacyBodyTwo), + ], + ), + ), + ); + } +} + +class _AboutPage extends StatelessWidget { + const _AboutPage(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AppDetailScaffold( + title: l10n.appTitle, + child: WorkbenchPanel( + accent: FocusSeedVisuals.of(context).focus, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.appTitle, + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w900), + ), + const SizedBox(height: 8), + Text(l10n.meSubtitle), + ], + ), + ), + ); + } +} + +class _ChoiceTile extends StatelessWidget { + const _ChoiceTile({ + required this.icon, + required this.title, + required this.selected, + required this.color, + required this.onTap, + }); + + final IconData icon; + final String title; + final bool selected; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return WorkbenchPanel( + accent: color, + fill: Color.alphaBlend( + color.withValues(alpha: selected ? 0.12 : 0.035), + scheme.surfaceContainerLowest, + ), + onTap: onTap, + child: Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: color.withValues(alpha: selected ? 0.18 : 0.10), + borderRadius: BorderRadius.circular(kWorkbenchRadius), + ), + child: Icon(icon, color: color), + ), + const SizedBox(width: 13), + Expanded( + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + ), + ), + AnimatedContainer( + duration: AppMotion.duration(context, AppMotion.fast), + width: 26, + height: 26, + decoration: BoxDecoration( + color: selected ? color : Colors.transparent, + shape: BoxShape.circle, + ), + child: + selected + ? Icon( + Icons.check_rounded, + color: scheme.onPrimary, + size: 18, + ) + : Icon(Icons.circle_outlined, color: scheme.outline), + ), + ], + ), + ); + } +} + +IconData _appearanceIcon(AppearancePreference value) { + return switch (value) { + AppearancePreference.system => Icons.auto_awesome_rounded, + AppearancePreference.light => Icons.light_mode_rounded, + AppearancePreference.dark => Icons.dark_mode_rounded, + }; +} + +String _appearanceLabel(AppLocalizations l10n, AppearancePreference value) { + return switch (value) { + AppearancePreference.system => l10n.system, + AppearancePreference.light => l10n.light, + AppearancePreference.dark => l10n.dark, + }; +} + +String _languageLabel(AppLocalizations l10n, LanguagePreference value) { + return switch (value) { + LanguagePreference.defaultLocale => l10n.languageDefault, + LanguagePreference.english => l10n.languageEnglish, + LanguagePreference.chinese => l10n.languageChinese, + }; +} diff --git a/lib/src/features/reflection/reflection_sheet.dart b/lib/src/features/reflection/reflection_sheet.dart index 6bce2e5..031d80b 100644 --- a/lib/src/features/reflection/reflection_sheet.dart +++ b/lib/src/features/reflection/reflection_sheet.dart @@ -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 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( context: context, @@ -24,9 +36,15 @@ Future 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 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 Function(String note, Mood mood) onSave; + final String contextTitle; + final String dateKey; + final Future 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, + ), ), ), - ], - ), + ), + ], ), ); } diff --git a/lib/src/features/shell/focus_seed_home.dart b/lib/src/features/shell/focus_seed_home.dart index da8b44b..bb08fe7 100644 --- a/lib/src/features/shell/focus_seed_home.dart +++ b/lib/src/features/shell/focus_seed_home.dart @@ -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 onLanguageChanged; final FocusSeedStore? focusSeedStore; + final bool showLandingSetupInitially; + final VoidCallback? onLandingSetupComplete; @override State createState() => _FocusSeedHomeState(); @@ -38,6 +49,13 @@ class _FocusSeedHomeState extends State { 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 { 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 _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 { } final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; - return AnnotatedRegion( - 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( + 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 { } } +class _FocusSeedNavigationBar extends StatelessWidget { + const _FocusSeedNavigationBar({ + required this.selectedIndex, + required this.onDestinationSelected, + }); + + final int selectedIndex; + final ValueChanged 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, diff --git a/lib/src/features/today/today_screen.dart b/lib/src/features/today/today_screen.dart index 8fe8604..91deb45 100644 --- a/lib/src/features/today/today_screen.dart +++ b/lib/src/features/today/today_screen.dart @@ -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 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 habits; + final String dateKey; + final ValueChanged 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, + ), + ); + } +} diff --git a/lib/src/features/today/widgets/compact_habit_row.dart b/lib/src/features/today/widgets/compact_habit_row.dart index 878e24d..b275f1e 100644 --- a/lib/src/features/today/widgets/compact_habit_row.dart +++ b/lib/src/features/today/widgets/compact_habit_row.dart @@ -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( + 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( - 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, + ), ), ), ), diff --git a/lib/src/features/today/widgets/daily_progress_rail.dart b/lib/src/features/today/widgets/daily_progress_rail.dart index 2b4d45d..29ce33a 100644 --- a/lib/src/features/today/widgets/daily_progress_rail.dart +++ b/lib/src/features/today/widgets/daily_progress_rail.dart @@ -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, ), ); } diff --git a/lib/src/features/today/widgets/focus_session_card.dart b/lib/src/features/today/widgets/focus_session_card.dart deleted file mode 100644 index 67c17d7..0000000 --- a/lib/src/features/today/widgets/focus_session_card.dart +++ /dev/null @@ -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( - 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), - ), - ], - ], - ); - } -} diff --git a/lib/src/features/today/widgets/habit_section.dart b/lib/src/features/today/widgets/habit_section.dart index 2dff4ea..3775b18 100644 --- a/lib/src/features/today/widgets/habit_section.dart +++ b/lib/src/features/today/widgets/habit_section.dart @@ -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( + 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), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/today/widgets/intention_card.dart b/lib/src/features/today/widgets/intention_card.dart index bb0af0c..e76c457 100644 --- a/lib/src/features/today/widgets/intention_card.dart +++ b/lib/src/features/today/widgets/intention_card.dart @@ -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, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/features/today/widgets/reflection_prompt.dart b/lib/src/features/today/widgets/reflection_prompt.dart index 50be749..5de429c 100644 --- a/lib/src/features/today/widgets/reflection_prompt.dart +++ b/lib/src/features/today/widgets/reflection_prompt.dart @@ -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( diff --git a/lib/src/l10n/app_en.arb b/lib/src/l10n/app_en.arb index 2dc75b5..de32936 100644 --- a/lib/src/l10n/app_en.arb +++ b/lib/src/l10n/app_en.arb @@ -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" } diff --git a/lib/src/l10n/app_zh.arb b/lib/src/l10n/app_zh.arb index df2532d..8a9db28 100644 --- a/lib/src/l10n/app_zh.arb +++ b/lib/src/l10n/app_zh.arb @@ -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": "写一句复盘" } diff --git a/lib/src/l10n/generated/app_localizations.dart b/lib/src/l10n/generated/app_localizations.dart index d2e677f..8189089 100644 --- a/lib/src/l10n/generated/app_localizations.dart +++ b/lib/src/l10n/generated/app_localizations.dart @@ -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. diff --git a/lib/src/l10n/generated/app_localizations_en.dart b/lib/src/l10n/generated/app_localizations_en.dart index 3e027ff..73d591b 100644 --- a/lib/src/l10n/generated/app_localizations_en.dart +++ b/lib/src/l10n/generated/app_localizations_en.dart @@ -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'; diff --git a/lib/src/l10n/generated/app_localizations_zh.dart b/lib/src/l10n/generated/app_localizations_zh.dart index 8adc352..da3e5e8 100644 --- a/lib/src/l10n/generated/app_localizations_zh.dart +++ b/lib/src/l10n/generated/app_localizations_zh.dart @@ -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 => '写一句复盘'; diff --git a/lib/src/l10n/l10n.dart b/lib/src/l10n/l10n.dart index 72d7f98..4fd326f 100644 --- a/lib/src/l10n/l10n.dart +++ b/lib/src/l10n/l10n.dart @@ -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 localizedStarterHabits(AppLocalizations l10n) { return [ l10n.defaultHabitPlanTask, diff --git a/lib/src/models/app_state.dart b/lib/src/models/app_state.dart index 77d71f2..3785c6f 100644 --- a/lib/src/models/app_state.dart +++ b/lib/src/models/app_state.dart @@ -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 habits; final List sessions; final List reflections; + final List actions; + final String selectedActionId; + final List progressNotes; + final List 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 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 sessionsForAction(String actionId) { + return sessions + .where((session) => session.actionId == actionId) + .toList(growable: false) + ..sort((a, b) => b.completedAt.compareTo(a.completedAt)); + } + + List 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? habits, List? sessions, List? reflections, + List? actions, + String? selectedActionId, + List? progressNotes, + List? 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 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>(); } + static List _stringList(Object? value) { + if (value is! List) { + return const []; + } + return [ + for (final item in value) + if (item is String && item.trim().isNotEmpty) item.trim(), + ]; + } + static AppState starter({List? 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')}-' diff --git a/lib/src/models/focus_session.dart b/lib/src/models/focus_session.dart index b00fcd0..66cccae 100644 --- a/lib/src/models/focus_session.dart +++ b/lib/src/models/focus_session.dart @@ -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 toJson() { return { 'id': id, 'minutes': minutes, + 'actualSeconds': actualSeconds, 'completedAt': completedAt.toIso8601String(), + 'intention': intention, + 'actionId': actionId, + 'actionTitleSnapshot': actionTitleSnapshot, }; } static FocusSession fromJson(Map 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?, ); } } diff --git a/lib/src/models/growth_stage.dart b/lib/src/models/growth_stage.dart deleted file mode 100644 index 9d5d290..0000000 --- a/lib/src/models/growth_stage.dart +++ /dev/null @@ -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', - }; -} diff --git a/lib/src/models/journey_stats.dart b/lib/src/models/journey_stats.dart deleted file mode 100644 index 1acd3bd..0000000 --- a/lib/src/models/journey_stats.dart +++ /dev/null @@ -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( - 0, - (total, key) => total + minutesForDate(state.sessions, key), - ); - final activeDays = dateKeys.where((key) => _hasProgress(state, key)).length; - final habitCompletions = state.habits.fold( - 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); - } -} diff --git a/lib/src/models/today_progress.dart b/lib/src/models/today_progress.dart index 73bd135..a321ba0 100644 --- a/lib/src/models/today_progress.dart +++ b/lib/src/models/today_progress.dart @@ -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; } diff --git a/lib/src/services/monetization_provider.dart b/lib/src/services/monetization_provider.dart deleted file mode 100644 index 2d016ce..0000000 --- a/lib/src/services/monetization_provider.dart +++ /dev/null @@ -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(); - Future showInterstitialIfEligible(AdPlacement placement); - Future startPurchase(String productId); - Future restorePurchases(); -} - -class NoopMonetizationProvider implements MonetizationProvider { - const NoopMonetizationProvider(); - - @override - bool get adsEnabled => false; - - @override - bool get billingEnabled => false; - - @override - Stream premiumState() => Stream.value(PremiumState.free); - - @override - Future showInterstitialIfEligible(AdPlacement placement) async {} - - @override - Future startPurchase(String productId) async { - return PurchaseResult.unavailable; - } - - @override - Future restorePurchases() async {} -} diff --git a/lib/src/widgets/dialogs.dart b/lib/src/widgets/dialogs.dart index b3c2613..acb09c0 100644 --- a/lib/src/widgets/dialogs.dart +++ b/lib/src/widgets/dialogs.dart @@ -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 showHabitDialog( BuildContext context, - ValueChanged onSubmit, { + Future Function(String value) onSubmit, { String initialValue = '', }) async { - var value = initialValue; - await showDialog( + 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 showIntentionDialog( BuildContext context, - ValueChanged onSubmit, { + Future Function(String value) onSubmit, { String initialValue = '', }) async { - var value = initialValue; - await showDialog( + 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 showTextEntrySheet({ + required BuildContext context, + required String title, + required String hintText, + required Future 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( + 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 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 _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 confirmDeleteHabit( BuildContext context, VoidCallback onConfirm, ) async { - await showDialog( + 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 confirmClearToday( BuildContext context, VoidCallback onConfirm, ) async { - await showDialog( + 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 confirmClearData( BuildContext context, VoidCallback onConfirm, ) async { - await showDialog( + 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 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( + 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( 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), + ], + ); + }, + ); + } } diff --git a/lib/src/widgets/focus_seed_header.dart b/lib/src/widgets/focus_seed_header.dart deleted file mode 100644 index bc6bb49..0000000 --- a/lib/src/widgets/focus_seed_header.dart +++ /dev/null @@ -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), - ], - ], - ), - ), - ); - } -} diff --git a/lib/src/widgets/growth_object.dart b/lib/src/widgets/growth_object.dart deleted file mode 100644 index a1e589a..0000000 --- a/lib/src/widgets/growth_object.dart +++ /dev/null @@ -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, - ), - ], - ), - ); - } -} diff --git a/lib/src/widgets/loading_view.dart b/lib/src/widgets/loading_view.dart index 61cae8d..5dd4cb8 100644 --- a/lib/src/widgets/loading_view.dart +++ b/lib/src/widgets/loading_view.dart @@ -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; } } diff --git a/lib/src/widgets/seed_illustration.dart b/lib/src/widgets/seed_illustration.dart deleted file mode 100644 index 9387985..0000000 --- a/lib/src/widgets/seed_illustration.dart +++ /dev/null @@ -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; - } -} diff --git a/lib/src/widgets/settings_tile.dart b/lib/src/widgets/settings_tile.dart index b721114..5be3d44 100644 --- a/lib/src/widgets/settings_tile.dart +++ b/lib/src/widgets/settings_tile.dart @@ -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 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), + ], + ), + ), + ), ); } } diff --git a/lib/src/widgets/surface_panel.dart b/lib/src/widgets/surface_panel.dart index 8b32397..e6a9a8b 100644 --- a/lib/src/widgets/surface_panel.dart +++ b/lib/src/widgets/surface_panel.dart @@ -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), + ), + ), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 5f5e8d7..941340a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/focus_seed_controller_test.dart b/test/focus_seed_controller_test.dart index cea2589..0f5109c 100644 --- a/test/focus_seed_controller_test.dart +++ b/test/focus_seed_controller_test.dart @@ -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); + }); } diff --git a/test/focus_seed_store_test.dart b/test/focus_seed_store_test.dart index fd1be83..c6cd38d 100644 --- a/test/focus_seed_store_test.dart +++ b/test/focus_seed_store_test.dart @@ -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'); }, ); diff --git a/test/growth_stage_test.dart b/test/growth_stage_test.dart deleted file mode 100644 index b86ff5e..0000000 --- a/test/growth_stage_test.dart +++ /dev/null @@ -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': [], - 'sessions': [], - 'reflections': [], - }); - - expect(state.anchorDateKey, isNotEmpty); - expect(state.toJson()['version'], 2); - }); -} diff --git a/test/widget_test.dart b/test/widget_test.dart index 79bacae..64be503 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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 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')); +}