diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..c84200f --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +# Forgejo-native CI for the Circuit-Forge focus-flow fork. +# +# Ashley's upstream .github/workflows/ci.yml targets GitHub-hosted +# ubuntu-latest/windows-latest/macos-latest runners, which don't exist on +# this self-hosted instance for non-org repos (that workflow shows every +# matrix job as "Has been cancelled" on eva/focus-flow). This workflow +# mirrors the working pattern already used by circuitforge-core, peregrine, +# and kiwi on this instance: ubuntu-latest only. + +name: CI + +on: + push: + branches: [main, 'feat/**', 'fix/**'] + pull_request: + branches: [main] + +jobs: + workspace: + name: Pure-Dart workspace packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub get + - run: dart analyze + - run: dart run scripts/test.dart + + flutter_app: + name: Flutter app (analyze, test, format) + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/focus_flow_flutter + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.44.4 + channel: stable + - run: flutter pub get + - run: flutter analyze + - run: flutter test + - run: dart format --set-exit-if-changed lib test diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..623fc91 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 FocusFlow contributors +# SPDX-License-Identifier: AGPL-3.0-only + +[extend] +useDefault = true + +[allowlist] +description = "Local Claude Code settings file - globally .gitignore'd, never enters git history; contains recorded bash command patterns (including auth headers) from prior approved tool calls, not repo secrets" +paths = [ + '''\.claude/settings\.local\.json''', +] diff --git a/AGENTS.md b/AGENTS.md index 4476fee..760e4b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ -# AGENTS.md — Codex Project Rules (SQLite‑First, July 2026) +# AGENTS.md — Codex Project Rules (SQLite‑First, July 2026) This document supersedes all previous agent rule files. Treat it as the single source of truth. @@ -10,9 +10,10 @@ This document supersedes all previous agent rule files. Treat it as the single s ## 0. Quick‑start for Codex * Blocks live in `Codex Documentation/Current Software Plan/`. -* Work strictly in numerical order (Block 19 → 20 … 29). -* Each block contains numbered `XHIGH` / `HIGH` chunks. -* Stop at every `BREAKPOINT` in a chunk; wait for confirmation before continuing. +* Work strictly in numerical order unless the active plan explicitly says otherwise. +* Treat each block or named plan as the primary work unit. +* New or updated plan documents must use blocks of work, implementation steps, tasks, or acceptance checks. +* Stop at any explicit `BREAKPOINT` in the active block or plan; wait for confirmation before continuing. * Before coding, run `scripts/bootstrap_dev.sh` to set up the local dev DB. --- @@ -21,13 +22,13 @@ This document supersedes all previous agent rule files. Treat it as the single s | Element | Rule | |---------|------| -| Local storage | **SQLite via Drift**, schemaVersion 1 | -| Abstraction | Domain‑only repository interfaces (`scheduler_persistence`) | +| Local storage | **SQLite via Drift**, schemaVersion 1 | +| Abstraction | Domain-only repository interfaces (`scheduler_persistence`) | | Swappable | New adapters must pass repository conformance tests | -| Backup | AES‑256‑GCM encrypted SQLite file (`Backup library`, Block 24) | -| Migrations | Drift migrations with tests (Block 20, Block 26) | +| Backup | AES-256-GCM encrypted SQLite file (`Backup library`, Block 24) | +| Migrations | Drift migrations with tests (Block 20, Block 26) | -No MongoDB runtime in V1. +SQLite is the selected V1 implementation database. Keep Drift and database details inside the SQLite persistence adapter; core/domain code must not import database libraries or depend on row shapes. --- @@ -35,15 +36,15 @@ No MongoDB runtime in V1. 1. Interfaces expose domain objects only. 2. Optimistic `revision` on every mutable save. -3. Owner scope parameter now for future multi‑user. -4. Adapters implement compare‑and‑set; core never overwrites stale revision. +3. Owner scope parameter now for future multi-user. +4. Adapters implement compare-and-set; core never overwrites stale revision. --- ## 3. Notification Rules -* Use `NotificationAdapter` (Block 21). -* Desktop implementation (Block 22), fake adapter for tests. +* Use `NotificationAdapter` (Block 21). +* Desktop implementation (Block 22), fake adapter for tests. * Core never imports platform APIs directly. --- @@ -51,7 +52,7 @@ No MongoDB runtime in V1. ## 4. Backup / Export Rules * Backup: encrypted SQLite (`.sqlite.aes`) via Backup library. -* Readable exports: JSON + CSV via `ExportController` (Block 23). +* Readable exports: JSON + CSV via `ExportController` (Block 23). --- @@ -64,16 +65,16 @@ No MongoDB runtime in V1. | Migration | `test/migration` | Drift schema upgrades | | Integration | `test/integration` | full stack InMemory + SQLite + fake notifications | -CI fails below **80 % line coverage**. +CI fails below **80% line coverage**. --- -## 6. Dev Scripts (Block 27) +## 6. Dev Scripts (Block 27) | Script | Description | |--------|-------------| | `bootstrap_dev.sh` | install deps, create dev DB | -| `dev.sh` | hot‑reload desktop run | +| `dev.sh` | hot-reload desktop run | | `test.sh` | all tests + coverage | | `package_release.sh` | build OS binaries | @@ -83,29 +84,48 @@ integrated. --- -## 6.1 File Hygiene +## 6.1 Code Documentation & File Hygiene All future project files must include the relevant SPDX metadata for their file -format. New Dart files must include file-level Dartdoc library docs and Dartdoc -comments for every class, enum, enum value, constructor, method, field, and -top-level declaration. When adding code, keep large feature surfaces organized -under descriptive subfolders instead of expanding flat top-level `src` or app -directories. +format. + +All new and modified code must be commented or documented in detail while the +work is being done. Update existing comments and docs whenever functionality, +contracts, invariants, side effects, persistence mappings, scheduling rules, or +error behavior change. Stale comments are treated as incorrect code. + +New Dart files must include file-level Dartdoc library docs and Dartdoc comments +for every class, enum, enum value, constructor, method, field, and top-level +declaration. Existing Dart declarations touched by a change must have their +Dartdoc added or updated as part of that same change. + +Keep files small and focused. If a file grows to cover multiple responsibilities, +split it into smaller files with clear names rather than expanding a broad +catch-all file. Use increasingly specific folder scopes, ordered from broad +package/feature ownership down to concrete responsibility. Prefer descriptive +folder names that explain the domain area or adapter boundary. + +Aim for fewer than 10 files per folder. This is a guideline, not a hard rule: +do not create artificial one-file folders merely to satisfy the count, and do +split crowded folders when the files naturally form smaller responsibility +groups. --- ## 7. Branch, Commit & CI -* Start each new block from `main` on a block branch named - `block-XX-simple-name` (for example, `block-20-sqlite-adapter`). -* Keep all chunk work for that block on the block branch. -* Commit every completed chunk before moving to the next chunk or breakpoint. +* Start each new block or plan from `main` on a dedicated branch named for the + work, such as `block-20-sqlite-adapter` or `plan-backlog-tab`. +* Keep all work for the active block or plan on its dedicated branch. +* Commit completed work after it is implemented, documented, and locally + validated. +* Do not leave completed work only in the working tree. * Use conventional commits (`feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `ci`). -* Do not leave completed chunk work only in the working tree. -* When the block is complete and verified, merge the block branch back into +* When a full plan is complete and no work remains for that plan, rerun the + required validation. If tests still pass, merge the plan branch back into `main`. -* CI matrix: ubuntu‑latest, windows‑latest, macos‑latest. +* CI matrix: ubuntu-latest, windows-latest, macos-latest. * `dart analyze` and `dart test` must pass. --- @@ -125,4 +145,4 @@ directories. --- -_Last updated: 2026-06-27_ +_Last updated: 2026-07-07_ diff --git a/Codex Documentation/Completed Plans/README.md b/Codex Documentation/Completed Plans/README.md index 1682da3..0997db7 100644 --- a/Codex Documentation/Completed Plans/README.md +++ b/Codex Documentation/Completed Plans/README.md @@ -12,6 +12,9 @@ Included documents cover: ADRs, public API baseline, and requirements traceability matrix. - UI Plan 1, the implemented compact Today timeline mockup plan, including its source mockups and completed block handoff notes. +- UI Plan 2, the implemented Backlog board plan, including public scheduler + read models, command-backed drawer actions, board summary, and validation + handoff notes. - Persistence Plan 1, the implemented SQLite runtime persistence slice for normal Flutter startup and close/reopen task lifecycle proof. - Date Selection 01, the completed day navigation and date picker plan for the diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_01_REPO_SCOPE_AND_ASSETS.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_01_REPO_SCOPE_AND_ASSETS.md new file mode 100644 index 0000000..54e6182 --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_01_REPO_SCOPE_AND_ASSETS.md @@ -0,0 +1,120 @@ + + + +# UI Plan 2 Block 01 — Repo Scope, Guardrails, and Mockup Assets + +**Goal:** Prepare the repo for Backlog board implementation without changing product behavior yet. + +--- + +## Block outcome + +After this block, Codex has verified repo conventions, copied/registered the mockups for local reference, captured baseline validation, and identified the smallest safe implementation path. + +--- + +## Chunk 1.1 — Read repo instructions and baseline state + +### Tasks + +1. Read `AGENTS.md` first. +2. Read root `README.md`. +3. Read `apps/focus_flow_flutter/README.md`. +4. Read UI Plan 1 summary and block files under: + - `Codex Documentation/Completed Plans/UI Plan 1 - Compact Today Timeline Mockup/` +5. Inspect current Backlog-related files: + - `apps/focus_flow_flutter/lib/widgets/backlog_pane.dart` + - `apps/focus_flow_flutter/lib/widgets/schedule_components.dart` + - `apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart` + - `apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart` + - `packages/scheduler_core/lib/src/application/application_management/**` + - `packages/scheduler_core/lib/src/scheduling/backlog/**` +6. Confirm that Flutter app code imports only public scheduler APIs. +7. Confirm current tests and expected command names. + +### Acceptance criteria + +- Codex understands that V1 is SQLite-first and Flutter must not import persistence adapters directly. +- Codex has identified whether domain/persistence changes are needed for notes/tags/remove/someday. +- No code behavior changed in this chunk. + +--- + +## Chunk 1.2 — Place mockup references in the plan folder + +### Tasks + +1. Ensure the plan folder contains: + - `mockups/backlog_board_default.png` + - `mockups/backlog_task_drawer.png` +2. Add or preserve `mockups/README.md` explaining which image represents which state. +3. Do not store generated image prompt text in code comments. +4. Do not overwrite existing UI Plan 1 mockups. + +### Acceptance criteria + +- Mockup files are available from the plan folder. +- The default and drawer states are unambiguous. + +--- + +## Chunk 1.3 — Run baseline validation + +### Tasks + +From repo root: + +```sh +dart analyze +dart test +``` + +From Flutter app: + +```sh +cd apps/focus_flow_flutter +flutter analyze +flutter test +``` + +If environment setup is missing, run the highest-confidence subset and record exact failures. + +### Acceptance criteria + +- Baseline validation result is known before coding. +- Existing failures are documented separately from new work. + +--- + +## Chunk 1.4 — Create implementation notes for scope boundaries + +### Tasks + +Create a short working note, either in the block commit message or a temporary implementation checklist, identifying: + +1. Which files currently render the sidebar and app shell. +2. Which controller/composition object will own navigation state. +3. Which read-model expansion is required for the board. +4. Which commands already exist and which must be added. +5. Which UI widgets can be reused from UI Plan 1: + - dark theme tokens, + - reward icon, + - difficulty bars, + - selected overlay patterns, + - top-bar button style patterns. + +### Acceptance criteria + +- Codex has a narrow implementation map before writing new code. +- No speculative large refactors are planned. + +BREAKPOINT: Stop here if baseline tests fail in a way that could hide regressions. + +--- + +## Block acceptance criteria + +- Repo instructions read. +- Mockup assets placed and documented. +- Baseline validation attempted and recorded. +- No unrelated code changed. diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_02_BACKLOG_READ_MODELS_AND_METADATA.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_02_BACKLOG_READ_MODELS_AND_METADATA.md new file mode 100644 index 0000000..5daeabe --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_02_BACKLOG_READ_MODELS_AND_METADATA.md @@ -0,0 +1,328 @@ + + + +# UI Plan 2 Block 02 — Backlog Read Models, Metadata, and Demo Data + +**Goal:** Add the application/read-model support needed to render the Backlog board, summary, and drawer from backend-owned data. + +--- + +## Block outcome + +After this block, the scheduler/application layer can provide a Backlog board projection with columns, summary statistics, detail metadata, and suggested slot previews. Flutter should not need to invent board grouping or summary counts. + +--- + +## Chunk 2.1 — Audit current backlog API and identify exact extension points + +### Tasks + +1. Inspect the public export path in `packages/scheduler_core/lib/scheduler_core.dart`. +2. Inspect existing: + - `GetBacklogRequest` + - `BacklogQueryResult` + - `BacklogItemReadModel` + - `BacklogView` + - `BacklogFilter` + - `BacklogSortKey` + - `V1ApplicationManagementUseCases.getBacklog` +3. Decide whether to extend existing types or add board-specific types. +4. Prefer adding board-specific read models if extending existing types would break existing tests or consumers. + +### Acceptance criteria + +- Existing simple backlog query remains compatible. +- New board query is public through `scheduler_core.dart` if Flutter needs it. + +--- + +## Chunk 2.2 — Add board bucket policy in pure Dart + +### Tasks + +Add a deterministic policy type in scheduler core, such as: + +- `BacklogBoardBucket` +- `BacklogBoardBucketPolicy` +- `BacklogBoardBucketResolution` + +Default V1 policy, unless repo docs already define a better one: + +1. `notNow` + - task has `BacklogTag.wishlist`, or + - priority is `veryLow`/`low` and not otherwise urgent, or + - duration is missing and task is not ready to schedule. +2. `breakUpFirst` + - duration is `>= 90`, or + - difficulty is `hard`/`veryHard`, or + - task has child previews / parent-child metadata indicating it is already broken down. +3. `needTimeBlock` + - duration is `>= 45`, or + - priority is `high`/`veryHigh`, or + - task is estimated to require focused work. +4. `doNext` + - default for schedule-ready flexible backlog items. + +Each resolution must include a short user-facing reason string. + +### Acceptance criteria + +- Policy is pure Dart. +- Policy is deterministic. +- Unit tests cover all four buckets and tie-breaking precedence. +- No Flutter code duplicates this policy. + +--- + +## Chunk 2.3 — Add board and summary read models + +### Tasks + +Add read models following repo style. Exact file paths may vary, but keep them under scheduler core application management read models. + +Required models: + +1. `BacklogBoardQueryResult` +2. `BacklogBoardColumnReadModel` +3. `BacklogBoardItemReadModel` +4. `BacklogBoardSummaryReadModel` +5. `BacklogDistributionReadModel` +6. `BacklogDistributionEntryReadModel` +7. `BacklogChildPreviewReadModel` +8. `BacklogTaskDetailReadModel` +9. `SuggestedSlotReadModel` +10. `ProjectOptionReadModel` if not already available. + +Required summary fields: + +- total task count, +- total estimated minutes, +- count by priority, +- count by project, +- count and percentage by duration bucket, +- planning tip copy. + +Required card fields: + +- task id, +- title, +- subtitle/notes preview, +- project id/name/color token, +- duration, +- priority, +- reward, +- difficulty, +- bucket, +- bucket reason, +- created/updated timestamps, +- child previews, +- tag strings. + +### Acceptance criteria + +- Read models are immutable where repo conventions expect immutability. +- Read models expose display-ready values only where appropriate; do not bury domain objects inside the UI-only result if that would leak unnecessary mutation surface. +- Existing `BacklogQueryResult` tests remain passing. + +--- + +## Chunk 2.4 — Add notes and freeform tags metadata if absent + +### Tasks + +Current `Task` appears to lack freeform notes and general tags. Add the minimal V1 metadata needed by the drawer: + +- `String? notes` +- `Set tags` or a small `TaskTag` value object if repo style requires validation. + +Validation rules: + +- notes may be null or non-empty trimmed string; blank notes normalize to null if the repo has normalization helpers. +- tag labels are trimmed. +- blank tags are rejected or filtered consistently. +- duplicate tags collapse case-insensitively if repo conventions support that; otherwise preserve normalized unique strings. +- impose a safe maximum count/length consistent with existing persistence payload guards. + +Update all affected layers: + +- `Task` constructor and `copyWith`. +- quick capture/create paths where tags/notes are supported. +- document mapping. +- persistence contract fields. +- SQLite task table and DAO/repository mapping. +- in-memory repository if it stores full domain objects. +- tests. + +If adding persistence migration is required, keep it within SQLite/Drift V1 conventions and add migration tests if existing schema tooling supports them. + +### Acceptance criteria + +- Existing tasks still construct without notes/tags. +- Notes/tags round-trip through repository mappings. +- Flutter receives notes/tags from public read models. +- No direct SQLite/Drift import leaks into Flutter. + +BREAKPOINT: Stop here if notes/tags would require a broad migration beyond the current plan. Ask Ashley whether drawer tags should be read-only placeholders or true V1 metadata. + +--- + +## Chunk 2.5 — Add board query use case + +### Tasks + +Add a method to `V1ApplicationManagementUseCases`, such as: + +```dart +Future> getBacklogBoard( + GetBacklogBoardRequest request, +) +``` + +Request should include: + +- `ApplicationOperationContext context`, +- selected `CivilDate localDate` for slot suggestions, +- optional search text, +- optional filters, +- sort key, +- group mode. + +Behavior: + +1. Load backlog tasks through repositories. +2. Load projects for display names/colors. +3. Load child relationships/previews as needed. +4. Apply search/filter/sort/group using application/core policies. +5. Resolve board bucket for every item. +6. Build summary from the same filtered item set. +7. Return stable column order: Do Next, Need Time Block, Break Up First, Not Now. + +### Acceptance criteria + +- Query does not mutate repositories. +- Query returns all four columns even when empty. +- Counts match filtered item set. +- Summary and board columns use the same source task list. +- Unit tests cover empty backlog, default seed-like backlog, search/filter, and summaries. + +--- + +## Chunk 2.6 — Add task detail query and suggested slot preview + +### Tasks + +Add a detail query method, such as: + +```dart +Future> getBacklogTaskDetail( + GetBacklogTaskDetailRequest request, +) +``` + +Behavior: + +1. Load selected backlog task. +2. Load project options. +3. Load notes/tags/duration/reward/difficulty/project. +4. Generate suggested slots without mutating state. +5. Suggested slots should respect locked/required time by using scheduler/application services or cloned scheduler input. +6. If exact slot-specific scheduling is not supported, return preview rows and mark which one corresponds to `next available`. + +Suggested slot labels for demo should support: + +- `Great Fit` +- `Okay` +- no-fit or conflict copy if applicable. + +### Acceptance criteria + +- Detail query returns not found if selected task no longer exists. +- Suggested slots do not write snapshots/tasks. +- Missing duration returns an empty suggestions list plus a display reason instead of crashing. +- Unit tests prove the query is read-only. + +--- + +## Chunk 2.7 — Expand demo seed data for Backlog board + +### Tasks + +Create or extend app demo seed files under `apps/focus_flow_flutter/lib/app/demo/`. + +Seed must include enough data to match the mockup: + +- 24 backlog tasks total. +- Column counts: + - Do Next: 5 + - Need Time Block: 6 + - Break Up First: 6 + - Not Now: 5 +- Projects: + - Home: 5 + - Work: 8 + - Personal: 5 + - Learning: 4 + - Side Project: 2 +- Priorities: + - High: 7 + - Medium: 9 + - Low: 8 +- Total estimated time: 12h 15m. +- Include visible mockup tasks: + - Reply to 3 emails + - Update project status + - Schedule dentist appt + - Clean up downloads folder + - Plan tomorrow + - Review Q3 budget + - Plan next week + - Define weekly goals + - Time block schedule + - Organize kitchen drawers + - Build analytics dashboard + - Write marketing strategy + - Complete online course + - Module 1: Foundations + - Module 2: Deep Dive + - Module 3: Practice + - Learn guitar + - Start YouTube channel + - Travel to Japan + - Home office upgrade + - Read 2 books +- `Review Q3 budget` must have: + - duration 60 min, + - Work project, + - High priority, + - reward 3/5 equivalent, + - difficulty 2/5 equivalent, + - notes shown in drawer, + - tags `finance` and `review`, + - suggested slots matching the drawer mockup labels. + +The remaining 3 tasks may be seed-only hidden lower in scroll or included below the fold. + +### Acceptance criteria + +- Seed data is domain/application data, not a hardcoded widget list. +- Default board can render from public read models. +- Summary totals match the mockup. + +--- + +## Block acceptance criteria + +- Public application/read models can produce the Backlog board, summary, and detail drawer data. +- Notes/tags support is either implemented end-to-end or explicitly deferred at the breakpoint with Ashley’s approval. +- Core/application tests cover bucket policy, summary, detail, and read-only suggested slots. +- Flutter code still does not know scheduler internals. + +## Validation after block + +```sh +dart analyze +dart test +cd apps/focus_flow_flutter +flutter analyze +flutter test +``` diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_03_NAVIGATION_AND_SCREEN_SHELL.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_03_NAVIGATION_AND_SCREEN_SHELL.md new file mode 100644 index 0000000..d7478a9 --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_03_NAVIGATION_AND_SCREEN_SHELL.md @@ -0,0 +1,162 @@ + + + +# UI Plan 2 Block 03 — Navigation and Backlog Screen Shell + +**Goal:** Add the Backlog tab route/screen shell and make the sidebar navigation render `Backlog` as active. + +--- + +## Block outcome + +After this block, the app can switch between the existing Today screen and the new Backlog screen shell. The Backlog shell matches the finalized header/control layout but may still use placeholder board content until later blocks fill it in. + +--- + +## Chunk 3.1 — Introduce app section navigation state + +### Tasks + +1. Add an app-level enum/model such as `FocusFlowSection` with values: + - `today` + - `backlog` + - `projects` + - `reports` + - `settings` +2. Move the currently hardcoded active `Today` sidebar state into `FocusFlowHome` or a small shell controller. +3. Update `Sidebar` to accept: + - `activeSection`, + - `onSectionSelected` callback. +4. Keep Projects/Reports/Settings visible. They may switch to placeholder empty screens or remain inert, but Today and Backlog must work. + +### Acceptance criteria + +- Today still renders as before when active. +- Backlog nav item can become active. +- Sidebar widget tests can verify active styling for Today and Backlog. + +--- + +## Chunk 3.2 — Add Backlog screen controller skeleton + +### Tasks + +Add controller/read-state classes under app conventions, for example: + +```text +apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart +apps/focus_flow_flutter/lib/models/backlog/backlog_board_screen_data.dart +``` + +Controller responsibilities: + +- load `BacklogBoardQueryResult`, +- track loading/ready/empty/failure states, +- track selected task id for drawer, +- track search/filter/sort/group UI values, +- request detail data for selected task, +- expose reload method after commands. + +Avoid canonical scheduling state in the controller. + +### Acceptance criteria + +- Controller can load the demo/app composition read model. +- Controller has no direct persistence dependency. +- Selection is UI-local and not persisted. + +--- + +## Chunk 3.3 — Extend composition factory + +### Tasks + +Update `DemoSchedulerComposition` or the current app composition to create: + +- Backlog board controller, +- Backlog command controller if needed, +- any date/context provider needed for suggestions. + +Behavior: + +- Use same owner/timezone/date context style as Today. +- Reuse existing in-memory application store seeded by domain objects. +- Ensure Today and Backlog share the same underlying demo store so commands can refresh both later. + +### Acceptance criteria + +- Backlog can load without constructing a separate unrelated fake app state. +- Existing Today seed still works. + +--- + +## Chunk 3.4 — Build Backlog screen shell + +### Tasks + +Create `BacklogBoardScreen` or equivalent under a feature folder such as: + +```text +apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart +``` + +Screen sections: + +1. Header title/subtitle row. +2. Header mode/settings row. +3. Search/filter/sort/group/new-task control row. +4. Content body with board region and summary panel slot. +5. Drawer overlay slot above content. + +Use mockup spacing: + +- main left/top padding close to current app gutter, +- title around 32–36 px, +- dark navy/black background, +- controls height around 40–44 px, +- right summary panel around 285–300 px wide. + +### Acceptance criteria + +- Backlog shell visually resembles the default mockup header/control skeleton. +- Summary panel area exists but may display placeholder data until Block 6. +- Board area exists but may display placeholder columns until Block 4. +- No white Material surfaces. + +--- + +## Chunk 3.5 — Implement loading/empty/error states + +### Tasks + +Use existing `ReadStateView` patterns only if they can match the mockup. Otherwise build Backlog-specific states: + +- Loading: subtle centered progress or skeleton columns. +- Empty: calm empty board with new-task CTA. +- Failure: compact error panel with retry. + +Do not show blame language. + +### Acceptance criteria + +- Controller state maps to UI states. +- Tests can render loading, empty, failure, and ready states. + +BREAKPOINT: Stop here for visual review if the shell deviates from the default mockup header/sidebar layout. + +--- + +## Block acceptance criteria + +- Backlog nav is functional. +- Backlog shell renders without board details. +- Today screen remains functional and visually unchanged except sidebar now supports active state. +- App still passes Flutter analyze/test. + +## Validation after block + +```sh +cd apps/focus_flow_flutter +flutter analyze +flutter test +``` diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_04_BOARD_COLUMNS_AND_CARDS.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_04_BOARD_COLUMNS_AND_CARDS.md new file mode 100644 index 0000000..ff2f12d --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_04_BOARD_COLUMNS_AND_CARDS.md @@ -0,0 +1,231 @@ + + + +# UI Plan 2 Block 04 — Board Columns and Task Cards + +**Goal:** Render the four finalized Backlog board columns and task cards from Backlog board read models. + +--- + +## Block outcome + +After this block, the default Backlog board displays all four columns, counts, visible tasks, card metadata, child previews, and add-task footers. + +--- + +## Chunk 4.1 — Add board visual tokens + +### Tasks + +Add or extend Flutter visual tokens for Backlog-specific accents: + +- Do Next green. +- Need Time Block orange/yellow. +- Break Up First purple. +- Not Now blue. +- High priority red flag. +- Medium priority yellow/orange flag. +- Low priority green flag. +- Project dot colors matching seed projects. +- Tag chip colors for drawer later. + +Prefer central token/mapping files over inline colors. + +### Acceptance criteria + +- Colors are centralized. +- Existing Today color mappings remain unchanged. +- No one-off hardcoded color scatter in card widgets. + +--- + +## Chunk 4.2 — Build column header widget + +### Tasks + +Create a reusable `BacklogBoardColumn` and `BacklogColumnHeader`. + +Header content: + +- bucket icon, +- bucket title, +- count pill, +- add icon button on the far right, +- subtitle line. + +Icon suggestions: + +- Do Next: check circle. +- Need Time Block: clock. +- Break Up First: target/radio button/segmented circle. +- Not Now: cloud outline. + +### Acceptance criteria + +- Header count is read from `BacklogBoardColumnReadModel.count`. +- Header add icon calls the same create-task flow as the footer with bucket preselection if available. +- Header does not compute bucket counts in Flutter. + +--- + +## Chunk 4.3 — Build board layout + +### Tasks + +1. Render columns in stable order: + - Do Next + - Need Time Block + - Break Up First + - Not Now +2. Use horizontal layout at desktop width. +3. Make board horizontally scrollable if width becomes constrained. +4. Make column card stacks vertically scrollable or allow the whole board body to scroll, whichever best matches current app ergonomics. +5. Keep the summary panel fixed on the right in default state. + +Sizing guidance at 1672 px mockup width: + +- board starts after left content gutter, +- 4 columns fit before summary panel, +- column width around 270–300 px, +- column gap around 8–12 px, +- column border/fill tinted by bucket accent. + +### Acceptance criteria + +- Four columns are visible at mockup-like desktop width. +- Board can be used at narrower desktop widths without overflow exceptions. +- Summary panel does not overlap default board state. + +--- + +## Chunk 4.4 — Build backlog task card widget + +### Tasks + +Create `BacklogTaskCard` with: + +- leading checkbox/selection square, +- title, +- subtitle/notes preview, +- metadata row: + - duration icon and minutes, + - project color dot/name, + - priority flag/label, +- footer: + - difficulty label and five bars, + - reward label and solid reward icons. + +Use existing `DifficultyBars` and reward icon widgets where possible. If existing widgets are Today-specific, extract reusable versions without breaking Today. + +Difficulty requirements: + +- exactly five bars, +- `veryEasy` = 1 filled, +- `easy` = 2 filled, +- `medium` = 3 filled, +- `hard` = 4 filled, +- `veryHard` = 5 filled, +- unfilled bars are outline/subtle. + +Reward requirements: + +- display 1–5 solid sparkle/bolt-style marks matching the mockup language, +- no line-only star outlines. + +### Acceptance criteria + +- Card content is read from `BacklogBoardItemReadModel`. +- Card supports selected state with magenta outline. +- Card click selects task and opens drawer in Block 7. +- Card does not mutate task status by itself. + +--- + +## Chunk 4.5 — Build child preview card section + +### Tasks + +For tasks with child previews, render nested child rows inside the parent card, as shown under: + +- `Plan next week` +- `Complete online course` + +Child row content: + +- checkbox-like icon, +- child title, +- optional duration at right. + +Interaction for this block: + +- Child rows may be non-interactive or select the parent unless a public child-detail model already exists. +- Do not implement child completion from the Backlog board unless command wiring is explicitly available and tested. + +### Acceptance criteria + +- Parent card remains visually coherent with child preview rows. +- Child previews come from read model, not title parsing. +- No scheduling/child completion rules are duplicated in Flutter. + +--- + +## Chunk 4.6 — Add column footer Add Task buttons + +### Tasks + +Footer content: + +- `+ Add task`, colored by bucket accent. + +Behavior for this block: + +- Calls a callback with intended bucket. +- Actual create drawer/command wiring happens in Block 8. + +### Acceptance criteria + +- Footer is visible in every column. +- Footer does not throw when pressed before Block 8 wiring; it can open a placeholder or no-op with a TODO callback in tests. + +--- + +## Chunk 4.7 — Tune board visuals against default mockup + +### Tasks + +Tune: + +- background fills, +- border opacities, +- column corner radius, +- card height, +- card vertical spacing, +- title and metadata font sizes, +- count pill styling, +- icon sizes, +- card selected/hover states. + +### Acceptance criteria + +- Default board content closely matches `mockups/backlog_board_default.png`. +- Visible mockup tasks are present in the expected columns. +- No white surfaces or accidental Material default cards. + +BREAKPOINT: Stop here for visual review if card density or column spacing is substantially off. + +--- + +## Block acceptance criteria + +- Backlog board renders from read models. +- All four columns, counts, visible task cards, child previews, difficulty bars, reward icons, and add footers are present. +- Board layout is responsive enough for desktop width changes. +- No domain mutations happen from basic card rendering. + +## Validation after block + +```sh +cd apps/focus_flow_flutter +flutter analyze +flutter test +``` diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_05_SEARCH_FILTER_SORT_GROUP.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_05_SEARCH_FILTER_SORT_GROUP.md new file mode 100644 index 0000000..a102596 --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_05_SEARCH_FILTER_SORT_GROUP.md @@ -0,0 +1,189 @@ + + + +# UI Plan 2 Block 05 — Search, Filter, Sort, Group, and Mode Controls + +**Goal:** Implement the Backlog top controls shown in the finalized default mockup with safe, deterministic behavior. + +--- + +## Block outcome + +After this block, the Backlog screen has working search/filter/sort/group controls that reload or transform the Backlog board without duplicating scheduling rules. + +--- + +## Chunk 5.1 — Search field + +### Tasks + +Add search field matching the mockup: + +- leading search icon, +- placeholder `Search backlog...`, +- dark outlined field, +- clear affordance when text is non-empty if it fits cleanly. + +Behavior: + +- Debounce user input lightly or trigger search on change if current data size is small. +- Search across title, subtitle/notes preview, project name, and tags. +- Prefer passing `searchText` into the board query so search is part of the read-model request. + +### Acceptance criteria + +- Typing filters cards and updates summary counts. +- Clearing search restores all tasks. +- Search does not alter persisted task data. + +--- + +## Chunk 5.2 — Filter menu + +### Tasks + +Implement `Filters` button and popover/menu. + +Minimum filters: + +- priority: High, Medium, Low, +- project, +- duration bucket, +- bucket/column, +- missing duration, +- no reward set if existing backlog filter supports it. + +Behavior: + +- Multi-select where useful. +- Apply and clear controls. +- Active filter count may be shown as a small badge if it does not clutter the mockup. +- Filter state should update board columns and summary from the same result set. + +### Acceptance criteria + +- Filter button opens a dark popover. +- Applying filters updates columns and summary. +- Clearing filters restores default board. +- Filter logic lives in application read policy or safe query layer, not widget-only scheduling logic. + +--- + +## Chunk 5.3 — Sort dropdown + +### Tasks + +Implement `Sort: Custom` dropdown. + +Sort options: + +- Custom/default. +- Priority. +- Reward vs Effort. +- Age. +- Project. +- Times Pushed if available. +- Duration if added to board sorting. + +Behavior: + +- Sort applies inside each bucket by default. +- Do not reorder bucket columns. +- Stable tie-breaker preserves source/read-model order. + +### Acceptance criteria + +- Sort selection updates card order. +- `Custom` default matches mockup seed ordering. +- Sort state is visible in the control label. + +--- + +## Chunk 5.4 — Group dropdown + +### Tasks + +Implement `Group: None` dropdown. + +Group options: + +- None. +- Project. +- Priority. +- Duration. + +Behavior: + +- `None` is default and matches mockup. +- Non-none group mode adds compact section dividers inside each column or transforms card ordering only if section dividers are visually too disruptive. +- Summary panel remains based on filtered item set, not group sections. + +### Acceptance criteria + +- Group dropdown opens and changes state. +- Group none returns the exact default visual organization. +- No overflow or large layout jump when grouping. + +--- + +## Chunk 5.5 — Compact/Board toggle and Settings button + +### Tasks + +Implement the upper-right controls: + +- `Compact` segment. +- `Board` segment active. +- `Settings` button. + +Behavior: + +- Board is active by default. +- If an existing compact backlog list can be safely reused, clicking Compact may swap to that list. Otherwise, keep Compact inert for this plan and document it in the widget callback TODO. +- Settings remains visible and inert unless there is an existing settings screen. + +### Acceptance criteria + +- Toggle visually matches mockup. +- Board remains active by default. +- Pressing Compact/Settings does not throw. +- Tests assert Board selected state. + +--- + +## Chunk 5.6 — New Task button shell + +### Tasks + +Add magenta `+ New Task` button with trailing chevron. + +Behavior for this block: + +- Button calls a callback or opens a placeholder drawer shell. +- Full create task wiring happens in Block 8. +- Dropdown chevron may later expose quick destination choices; do not implement a broad menu unless already designed. + +### Acceptance criteria + +- Button matches the mockup visually. +- Button has a stable key for tests. +- Button press is safe before full Block 8 wiring. + +BREAKPOINT: Stop here if control behavior requires unresolved product decisions about compact mode or filter semantics. + +--- + +## Block acceptance criteria + +- Search/filter/sort/group controls are present and safe. +- Default state matches mockup. +- Interactions update or preserve board data predictably. +- No scheduling rules are duplicated in Flutter. + +## Validation after block + +```sh +cd apps/focus_flow_flutter +flutter analyze +flutter test +``` diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_06_SUMMARY_PANEL.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_06_SUMMARY_PANEL.md new file mode 100644 index 0000000..d3b17ed --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_06_SUMMARY_PANEL.md @@ -0,0 +1,176 @@ + + + +# UI Plan 2 Block 06 — Backlog Summary Panel + +**Goal:** Implement the right-side default Backlog summary panel from the finalized mockup. + +--- + +## Block outcome + +After this block, the default Backlog state includes a data-driven summary panel with totals, distributions, duration donut, and planning tip. + +--- + +## Chunk 6.1 — Build summary panel structure + +### Tasks + +Create `BacklogSummaryPanel` under the backlog feature widgets. + +Panel sections: + +1. Header: + - sparkle icon, + - `Backlog summary`, + - collapse chevron. +2. Totals: + - total tasks, + - total estimated time. +3. Priority distribution. +4. Project distribution. +5. Duration distribution. +6. Planning tip card. + +### Acceptance criteria + +- Panel consumes `BacklogBoardSummaryReadModel`. +- No summary value is hardcoded in the widget. +- Panel aligns to the right of the default board layout. + +--- + +## Chunk 6.2 — Totals and distribution rows + +### Tasks + +Implement reusable compact distribution rows: + +- icon/dot/flag at left, +- label, +- count at right. + +Priority rows: + +- High: red flag. +- Medium: yellow flag. +- Low: green flag. + +Project rows: + +- colored dot. +- project name. +- count. + +### Acceptance criteria + +- Distribution rows match mockup density. +- Counts come from summary read model. +- Project colors use centralized visual token mapping. + +--- + +## Chunk 6.3 — Duration donut chart + +### Tasks + +Implement a small donut chart using Flutter painting, not external chart dependencies unless already present. + +Segments: + +- `0–30 min` +- `30–60 min` +- `60–120 min` +- `120+ min` + +Behavior: + +- Use read-model percentages/counts. +- Show legend at right with count and percentage. +- Handle zero-task state gracefully. + +### Acceptance criteria + +- Donut is visually similar to mockup. +- Widget does not crash for empty or all-zero distribution. +- Tests can find legend labels/counts. + +--- + +## Chunk 6.4 — Planning tip card + +### Tasks + +Render planning tip: + +- sparkle icon, +- title `Planning tip`, +- copy: `Pick 2–4 tasks for today. Keep it realistic and leave space for focus.` + +The initial tip may be static in read model. Later plans can make tips dynamic. + +### Acceptance criteria + +- Tip card matches dark glass styling. +- Text uses calm product language. + +--- + +## Chunk 6.5 — Collapse behavior + +### Tasks + +Implement chevron behavior: + +- Expanded by default. +- Clicking collapse hides body sections and leaves a compact header or narrow rail if it fits the design. +- Collapsed state is UI-local only. + +If collapse visual is not in mockup beyond chevron, simple body hide is enough. + +### Acceptance criteria + +- Collapse/expand works. +- Board widens or summary area reduces only in default summary mode; drawer overlay behavior remains unchanged. +- No persisted state change. + +--- + +## Chunk 6.6 — Tune panel visuals + +### Tasks + +Tune: + +- panel width, +- border opacity, +- header spacing, +- section dividers, +- row heights, +- donut size, +- planning tip background. + +### Acceptance criteria + +- Panel closely matches `mockups/backlog_board_default.png`. +- At mockup width, panel is fully visible and does not scroll independently unless content exceeds height. + +BREAKPOINT: Stop here for visual review if donut or summary spacing substantially differs from the mockup. + +--- + +## Block acceptance criteria + +- Default Backlog screen includes summary panel. +- All panel values come from read model. +- Collapse state works. +- Search/filter changes update summary values. + +## Validation after block + +```sh +cd apps/focus_flow_flutter +flutter analyze +flutter test +``` diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_07_TASK_DETAIL_DRAWER.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_07_TASK_DETAIL_DRAWER.md new file mode 100644 index 0000000..18ddb7d --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_07_TASK_DETAIL_DRAWER.md @@ -0,0 +1,272 @@ + + + +# UI Plan 2 Block 07 — Right-Anchored Task Detail Drawer + +**Goal:** Implement the selected-task drawer shown in the finalized drawer mockup. + +--- + +## Block outcome + +After this block, selecting a backlog card opens a right-side drawer that overlays the board without shifting any existing elements. + +--- + +## Chunk 7.1 — Add selection and detail loading behavior + +### Tasks + +1. Store selected task id in `BacklogBoardController`. +2. On card click: + - set selected task id, + - request `BacklogTaskDetailReadModel`, + - keep current board data unchanged. +3. If another card is selected while drawer is open, replace detail content without closing first. +4. If selected task disappears after a command/reload, close drawer and show quiet status if appropriate. + +### Acceptance criteria + +- Card click opens drawer. +- Selecting another card changes drawer content. +- Selection is UI-local. +- Detail query failures are rendered as drawer-local error, not full screen crash. + +--- + +## Chunk 7.2 — Implement drawer overlay anchoring + +### Tasks + +Use a `Stack` at the Backlog screen body level. + +Drawer layout: + +```dart +Positioned( + top: 0, + right: 0, + bottom: 0, + width: drawerWidth, + child: BacklogTaskDrawer(...), +) +``` + +Sizing: + +- Preferred width: 460–470 px. +- Clamp to available width minus safe left minimum for small desktop windows. +- Drawer height fills content area inside app frame. + +Behavior: + +- Do not shift columns or summary panel. +- Do not use a heavy scrim. +- Board remains visible behind/left of drawer. +- Close icon closes drawer. +- Escape key may close drawer if easy and testable. + +### Acceptance criteria + +- Drawer overlays content without moving board columns. +- No layout jump occurs when drawer opens/closes. +- Drawer remains attached to right edge during window resize. + +--- + +## Chunk 7.3 — Drawer header + +### Tasks + +Header content: + +- bucket/status icon with accent color, +- selected task title, +- sparkle icon at right of title area if available, +- close icon button in top-right, +- subtitle/description, +- age metadata such as `Added 2h ago (Jul 3, 2024 • 6:17 AM)`. + +Behavior: + +- Metadata should be display-ready from read model or a UI formatter that only formats dates/times. +- Title wraps at sensible line count. + +### Acceptance criteria + +- `Review Q3 budget` header matches drawer mockup for seeded task. +- Close button works. + +--- + +## Chunk 7.4 — Notes, project, and tags sections + +### Tasks + +Render: + +1. Notes panel: + - label `Notes`, + - notes body. +2. Project selector: + - label `Project`, + - colored dot, + - project name, + - dropdown chevron. +3. Tags section: + - label `Tags`, + - tag chips, + - add button. + +Behavior: + +- If update commands exist by this block, project/tags can be editable. +- If update commands are deferred to Block 8, controls should call placeholder callbacks and not mutate state. +- Do not invent Flutter-only persisted tags. + +### Acceptance criteria + +- Seeded `finance` and `review` chips display for `Review Q3 budget`. +- Empty notes/tags render calm empty states. +- Project and tag values come from detail read model. + +--- + +## Chunk 7.5 — Metric tiles + +### Tasks + +Render three tiles: + +1. Duration: + - clock icon, + - `60 min`, + - `Estimated`. +2. Reward: + - reward icon, + - `3/5`, + - `Satisfying` or equivalent label. +3. Difficulty: + - difficulty icon, + - `2/5`, + - `Easy` or equivalent label. + +Use centralized mapping helpers for reward/difficulty numeric labels. + +### Acceptance criteria + +- Tiles match drawer mockup density. +- Values update for different selected tasks. +- Missing values render `Not set` without throwing. + +--- + +## Chunk 7.6 — Suggested slots section + +### Tasks + +Render: + +- section title `Suggested slots`, +- `View day` button, +- rows with calendar icon, date label, time range, duration, fit badge, chevron. + +Rows must support: + +- green `Great Fit`, +- yellow/orange `Okay`, +- future/tomorrow labels. + +Behavior: + +- Rows may set a selected suggested slot id if slot-specific scheduling is implemented in Block 8. +- Otherwise rows are preview-only and pressing primary `Schedule` uses backend next-available command. + +### Acceptance criteria + +- Seeded drawer shows three suggested slot rows similar to mockup. +- Missing duration or no-fit returns a calm explanation. +- Slot rows are generated by read model/scheduler preview, not widget-only calculations. + +--- + +## Chunk 7.7 — Drawer action buttons + +### Tasks + +Render action buttons: + +- Primary magenta `Schedule`. +- Secondary `Break Up`. +- Full-width `Push to Someday`. +- Full-width `Remove`. + +Behavior in this block: + +- Buttons call callbacks supplied by parent. +- Actual commands are finalized in Block 8. +- Disable only when the read model explicitly says the action is unavailable. Do not make them look disabled because wiring is pending; instead route callbacks safely. + +### Acceptance criteria + +- Buttons visually match drawer mockup. +- Button presses do not throw before command wiring. +- Stable keys exist for widget tests. + +--- + +## Chunk 7.8 — Selected card styling behind drawer + +### Tasks + +When selected: + +- card gets magenta border/outline, +- checkbox/leading control reflects selected/focused state if visually needed, +- underlying board remains scrollable if drawer does not cover that area. + +### Acceptance criteria + +- `Review Q3 budget` selected card matches drawer mockup highlight. +- Closing drawer clears selected styling. + +--- + +## Chunk 7.9 — Drawer polish and accessibility + +### Tasks + +Polish: + +- drawer background, border, radius, +- internal section spacing, +- text sizes, +- button heights, +- hover/focus states, +- semantic labels for close and action buttons. + +### Acceptance criteria + +- Drawer closely matches `mockups/backlog_task_drawer.png`. +- Drawer is keyboard reachable enough for desktop tests. +- No white surfaces. + +BREAKPOINT: Stop here for visual review if drawer is not right-anchored or if it shifts the board. + +--- + +## Block acceptance criteria + +- Clicking a task opens the right drawer. +- Drawer overlays without shifting layout. +- Drawer content is data-driven. +- Close behavior works. +- Selected card styling works. + +## Validation after block + +```sh +cd apps/focus_flow_flutter +flutter analyze +flutter test +``` diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_08_COMMANDS_CREATE_AND_ACTIONS.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_08_COMMANDS_CREATE_AND_ACTIONS.md new file mode 100644 index 0000000..91f2a12 --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_08_COMMANDS_CREATE_AND_ACTIONS.md @@ -0,0 +1,267 @@ + + + +# UI Plan 2 Block 08 — Commands, New Task, Create/Edit Metadata, and Drawer Actions + +**Goal:** Wire the Backlog tab’s visible actions to application commands while preserving backend/domain authority. + +--- + +## Block outcome + +After this block, `New Task`, `Add task`, `Schedule`, `Break Up`, `Push to Someday`, `Remove`, and metadata edits are either implemented through application commands or explicitly left as safe non-mutating placeholders with tests and TODO notes. + +--- + +## Chunk 8.1 — Add/update task metadata command + +### Tasks + +Add an application command/use case such as `updateBacklogTaskDetails` or general `updateTaskDetails`. + +Input fields: + +- task id, +- expected updated timestamp/revision if available, +- title, +- notes, +- project id, +- duration minutes, +- priority, +- reward, +- difficulty, +- tags, +- backlog tags. + +Behavior: + +- Validate task exists. +- Validate stale update guard using existing `expectedUpdatedAt` style or repository revision style. +- Preserve schedule/source-of-truth rules. +- For backlog tasks, metadata changes should not schedule the task. +- For planned tasks, do not use this command from Backlog UI. + +### Acceptance criteria + +- Metadata update command is tested. +- Notes/tags/project edits persist through repository layer. +- Flutter calls command controller, not repositories. + +--- + +## Chunk 8.2 — New Task drawer/create flow + +### Tasks + +Implement reusable task editor drawer/sheet for Backlog create mode. + +Open from: + +- top `+ New Task`, +- column header add icon, +- column footer `+ Add task`. + +Fields: + +- title required, +- notes optional, +- project optional/default Inbox or selected bucket/project default, +- duration optional, +- priority default Medium, +- reward optional/not set, +- difficulty optional/not set, +- tags optional. + +Behavior: + +- Create through `quickCaptureToBacklog` or a new richer create command if notes/tags are required. +- Do not force scheduling. +- On success: + - close create drawer, + - refresh Backlog board, + - show quiet success text if existing command banner pattern supports it. +- Empty title validation stays inline and preserves input. + +### Acceptance criteria + +- `New Task` creates a backlog task. +- Column Add Task can preselect or hint bucket defaults without writing a fake bucket field. +- Created task appears in board after refresh according to bucket policy. + +--- + +## Chunk 8.3 — Schedule selected backlog task + +### Tasks + +Wire drawer `Schedule` to existing command: + +```dart +scheduleBacklogItem( + taskId: selectedTaskId, + durationMinutes: optionalDurationIfEdited, + expectedUpdatedAt: selectedUpdatedAt, +) +``` + +Behavior: + +- If duration is missing, focus/show duration requirement instead of discarding task. +- If scheduling succeeds: + - task leaves Backlog board if its status changes to planned, + - drawer closes, + - summary and counts refresh, + - command status shows calm success. +- If no fit/conflict: + - keep task in Backlog, + - show drawer-local or command-banner explanation. + +### Acceptance criteria + +- UI does not choose canonical time slot by itself. +- Schedule command handles stale updates. +- Tests verify success callback and failure display with fake command controller. + +--- + +## Chunk 8.4 — Break Up flow + +### Tasks + +Use existing `breakUpTask` command if possible. + +Minimum V1 UI: + +- Pressing `Break Up` opens a focused child-task drawer/dialog. +- User can add at least two child rows with title and optional duration. +- Saving calls `breakUpTask` with `ApplicationChildTaskDraft` entries. +- On success: + - refresh board, + - selected detail updates or drawer closes, + - child previews appear under parent. + +If full child-entry UI is too large for this plan, implement a safe placeholder that opens a drawer-local message: `Break-up flow is coming next` and do not mutate state. Only use placeholder if Ashley approves at breakpoint. + +### Acceptance criteria + +- No child tasks are created in Flutter-only state. +- Break-up command path is covered by tests if implemented. + +BREAKPOINT: Stop before using a placeholder for Break Up. Ask Ashley whether to include full child-entry UI now or defer. + +--- + +## Chunk 8.5 — Push to Someday command + +### Tasks + +Add/wire application command such as `pushBacklogTaskToSomeday`. + +Behavior: + +- Task remains backlog. +- Add `BacklogTag.wishlist` or equivalent someday metadata. +- Clear any selected scheduling preview if needed. +- Refresh board so task appears in `Not Now`. +- Record task activity/statistics if existing lifecycle service supports it. + +### Acceptance criteria + +- Command is tested. +- It is non-destructive. +- It does not schedule or delete the task. +- UI refreshes board and drawer content. + +--- + +## Chunk 8.6 — Remove/archive command + +### Tasks + +Add/wire application command such as `removeBacklogTask`. + +Assumed behavior: + +- non-destructive archive from active backlog, +- prefer `TaskStatus.noLongerRelevant` unless repo conventions define `cancelled` or a separate archive flag, +- require confirmation before applying, +- close drawer on success, +- refresh board/summary. + +Confirmation copy: + +- Title: `Remove from Backlog?` +- Body: `This hides the task from active planning. It will not schedule anything.` +- Actions: `Cancel`, `Remove`. + +### Acceptance criteria + +- Remove does not physically delete repository data unless Ashley explicitly changed the product decision. +- User can cancel confirmation. +- Success refreshes the board. + +--- + +## Chunk 8.7 — Project and tags inline edits + +### Tasks + +Once update command exists: + +- Project dropdown updates project id. +- Tag add button opens small input or menu. +- Tag chip can be removed if design supports it. +- Use optimistic UI only if command result can reconcile cleanly; otherwise reload after save. + +If inline autosave feels too chatty, use a small `Save changes` action inside the drawer only when fields are dirty. The mockup does not show a save button, so prefer careful inline save with visible running/success/failure states. + +### Acceptance criteria + +- Project/tags update through command controller. +- Stale/failure result is visible and does not lose local input. +- Tests cover project/tag command callback. + +--- + +## Chunk 8.8 — Command state feedback + +### Tasks + +Add a Backlog command status pattern: + +- running state near top controls or drawer action area, +- success state quiet and auto-dismissable if existing app pattern supports it, +- failure state calm and specific. + +Messages: + +- `Added to Backlog.` +- `Scheduled.` +- `Kept in Backlog — no fitting slot found.` +- `Moved to Someday.` +- `Removed from active Backlog.` + +### Acceptance criteria + +- Failures do not crash screen. +- User input is not lost on validation failure. +- Command controller reloads board on success. + +--- + +## Block acceptance criteria + +- Visible Backlog actions either work through application commands or have explicit Ashley-approved deferral. +- Create/schedule/someday/remove paths are implemented and tested. +- Break Up is implemented or explicitly deferred at its breakpoint. +- No Flutter widget mutates domain state directly. + +## Validation after block + +```sh +dart analyze +dart test +cd apps/focus_flow_flutter +flutter analyze +flutter test +``` diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_09_TESTING_VALIDATION_HANDOFF.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_09_TESTING_VALIDATION_HANDOFF.md new file mode 100644 index 0000000..bed69bd --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_BLOCK_09_TESTING_VALIDATION_HANDOFF.md @@ -0,0 +1,263 @@ + + + +# UI Plan 2 Block 09 — Testing, Validation, Polish, and Handoff + +**Goal:** Complete tests, final visual polish, validation commands, and handoff notes for the Backlog tab. + +--- + +## Block outcome + +After this block, the Backlog board implementation is tested, validated, documented, and ready for Ashley review or the next implementation plan. + +--- + +## Chunk 9.1 — Core/application tests + +### Tests + +Add or update tests for: + +1. Backlog bucket policy: + - Do Next, + - Need Time Block, + - Break Up First, + - Not Now, + - precedence/tie-breaking. +2. Backlog board query: + - empty state, + - 24-task seed-like board, + - search, + - filters, + - sort, + - group none/default, + - summary totals. +3. Backlog detail query: + - selected task detail, + - notes/tags/project metadata, + - suggested slots, + - missing duration/no-fit. +4. Commands: + - create backlog task, + - update metadata, + - schedule backlog item, + - push to someday, + - remove/archive, + - break up if implemented. + +### Acceptance criteria + +- Tests are deterministic. +- Tests do not require Flutter. +- Tests do not depend on wall-clock time without injected context. + +--- + +## Chunk 9.2 — Flutter widget tests + +### Tests + +Add tests under `apps/focus_flow_flutter/test/` that verify: + +1. Sidebar renders `Backlog` active on Backlog screen. +2. Default Backlog screen shows: + - `Backlog`, + - `24 tasks`, + - search placeholder, + - `Filters`, + - `Sort: Custom`, + - `Group: None`, + - `New Task`, + - all four column names, + - `Backlog summary`. +3. Visible seed cards render in expected columns. +4. `Review Q3 budget` card opens drawer. +5. Drawer shows: + - title, + - notes, + - Work project, + - `finance`, + - `review`, + - `60 min`, + - reward/difficulty labels, + - suggested slots, + - all action buttons. +6. Close button closes drawer and clears selected styling. +7. Search narrows visible cards and updates state. +8. Filter/sort/group menus open without throwing. +9. New Task opens create drawer; empty title validation works. +10. Command callbacks are invoked for Schedule/Someday/Remove using fakes/mocks. + +### Acceptance criteria + +- Widget tests avoid brittle pixel assertions. +- Stable keys are used for important controls. +- Tests remain readable and scoped. + +--- + +## Chunk 9.3 — Forbidden import and boundary tests + +### Tasks + +Update or preserve `test/forbidden_imports_test.dart` so app still cannot import: + +- `scheduler_core/src`, +- Drift/SQLite adapters, +- backup/export internals, +- notification platform internals, +- direct OS APIs for this UI slice. + +### Acceptance criteria + +- Boundary test passes. +- Any new controller/widget files are covered by forbidden import scan. + +--- + +## Chunk 9.4 — Responsive and overflow validation + +### Tasks + +Manually or via widget tests inspect Backlog layout at widths: + +- 1672 px reference width, +- 1440 px, +- 1280 px, +- 1100 px if supported. + +Expected behavior: + +- desktop reference matches mockup closely, +- narrower widths use horizontal board scroll or reduce column widths without overflow, +- drawer remains anchored right, +- drawer never shifts board elements, +- summary can collapse or be hidden below a sensible width if needed. + +### Acceptance criteria + +- No RenderFlex overflow in tested widths. +- Drawer overlay behavior remains correct. + +--- + +## Chunk 9.5 — Final visual polish pass + +### Tasks + +Compare against both mockups and tune: + +- sidebar active state, +- main gutters, +- title/count pill, +- controls spacing, +- column widths/heights, +- card density, +- icons, +- difficulty/reward indicators, +- summary panel, +- drawer width/spacing, +- selected card highlight, +- button heights. + +Allowed deviations: + +- minor font metric differences, +- safe hit target sizing, +- small responsive adjustments, +- platform-specific rendering differences. + +Not allowed: + +- white backgrounds, +- drawer shifting columns, +- cards not data-driven, +- missing summary panel, +- missing visible action buttons. + +### Acceptance criteria + +- Backlog default state and drawer state are close enough for Ashley review. + +BREAKPOINT: Stop here for visual review if any major visual section is missing. + +--- + +## Chunk 9.6 — Run validation commands + +### Commands + +From repo root: + +```sh +dart format --set-exit-if-changed . +dart analyze +dart test +``` + +From Flutter app: + +```sh +cd apps/focus_flow_flutter +flutter analyze +flutter test +``` + +If supported by environment: + +```sh +scripts/test.sh +``` + +### Acceptance criteria + +- Validation passes, or exact environment-related blockers are documented. +- New failures are fixed before handoff unless Ashley approves deferral. + +--- + +## Chunk 9.7 — Update docs and handoff + +### Tasks + +Update relevant docs: + +- `apps/focus_flow_flutter/README.md` current scope. +- `Codex Documentation/Current Software Plan/README.md` if this plan becomes the active plan. +- Optional implementation summary in completed plans if the repo tracks completed plan copies after execution. + +Handoff summary must include: + +- files changed, +- implementation decisions, +- commands run and results, +- features implemented, +- deferred items, +- known risks, +- next recommended plan. + +### Acceptance criteria + +- A new developer/Codex run can understand what changed. +- Deferred items are explicit, not hidden. + +--- + +## Final acceptance checklist + +- [ ] Backlog navigation works. +- [ ] Default board matches mockup. +- [ ] Drawer state matches mockup. +- [ ] Drawer overlays and does not shift layout. +- [ ] Four board buckets and counts are data-driven. +- [ ] Summary panel is data-driven. +- [ ] Search/filter/sort/group controls are safe. +- [ ] New Task/Add Task create backlog tasks. +- [ ] Schedule uses backend/application command. +- [ ] Push to Someday is non-destructive. +- [ ] Remove is non-destructive unless Ashley explicitly changed this. +- [ ] Break Up works or is explicitly deferred with approval. +- [ ] Notes/tags/project metadata is either implemented end-to-end or explicitly deferred with approval. +- [ ] Flutter app avoids forbidden imports. +- [ ] Tests pass or blockers are documented. diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_EXECUTION_ORDER.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_EXECUTION_ORDER.md new file mode 100644 index 0000000..c11f418 --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_EXECUTION_ORDER.md @@ -0,0 +1,48 @@ + + + +# UI Plan 2 — Execution Order + +Follow this order exactly. Stop at each `BREAKPOINT` if Codex is operating in an interactive repo session. + +1. `UI_PLAN_2_BLOCK_01_REPO_SCOPE_AND_ASSETS.md` +2. `UI_PLAN_2_BLOCK_02_BACKLOG_READ_MODELS_AND_METADATA.md` +3. `UI_PLAN_2_BLOCK_03_NAVIGATION_AND_SCREEN_SHELL.md` +4. `UI_PLAN_2_BLOCK_04_BOARD_COLUMNS_AND_CARDS.md` +5. `UI_PLAN_2_BLOCK_05_SEARCH_FILTER_SORT_GROUP.md` +6. `UI_PLAN_2_BLOCK_06_SUMMARY_PANEL.md` +7. `UI_PLAN_2_BLOCK_07_TASK_DETAIL_DRAWER.md` +8. `UI_PLAN_2_BLOCK_08_COMMANDS_CREATE_AND_ACTIONS.md` +9. `UI_PLAN_2_BLOCK_09_TESTING_VALIDATION_HANDOFF.md` + +Minimum validation after each block: + +```sh +cd apps/focus_flow_flutter +flutter analyze +flutter test +``` + +Additional validation after blocks that touch packages: + +```sh +dart analyze +dart test +``` + +Full project validation at the end, if available in the working environment: + +```sh +scripts/test.sh +``` + +Commit guidance: + +- Commit after each completed block. +- Use conventional commit style. +- Example block commits: + - `feat(backlog): add board read models` + - `feat(ui): add backlog navigation shell` + - `feat(ui): render backlog board cards` + - `feat(ui): add backlog detail drawer` + - `test(ui): cover backlog board interactions` diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md new file mode 100644 index 0000000..dc8d1cd --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_IMPLEMENTATION_NOTES.md @@ -0,0 +1,244 @@ + + + +# UI Plan 2 Implementation Notes + +## Block 01 Baseline Map + +1. Sidebar and app shell currently render through + `apps/focus_flow_flutter/lib/widgets/sidebar.dart`, + `apps/focus_flow_flutter/lib/widgets/app_shell.dart`, and the + `FocusFlowHome` parts under `apps/focus_flow_flutter/lib/app/home/`. +2. Navigation state is not implemented yet. `Sidebar` hard-codes Today as + active, and `FocusFlowHome` owns only Today controllers. UI Plan 2 should add + app-level section state in the home/composition layer before rendering the + Backlog board. +3. Backlog board data needs new public scheduler application read models. The + current `BacklogQueryResult` is list-shaped and returns raw `Task` values via + `BacklogItemReadModel`, which is not enough for board columns, summary, + detail drawer metadata, or suggested slot previews. +4. Existing commands include quick capture to backlog, schedule backlog item to + next available slot, move flexible tasks to backlog, remove task, and break + up task. Push-to-Someday and non-destructive backlog archive still need + command-level decisions before the drawer actions can be final. +5. UI Plan 1 components to reuse include `FocusFlowTokens`, the dark shell, + timeline reward icon, difficulty bars, selected overlay patterns, and the + top-bar icon/segmented-control styling conventions. + +## Block 01 Validation + +- `scripts/bootstrap_dev.sh`: passed. +- `dart analyze`: passed. +- `dart test`: passed. +- `flutter analyze` in `apps/focus_flow_flutter`: passed. +- `flutter test` in `apps/focus_flow_flutter`: passed. + +## Block 02 Audit + +1. `GetBacklogRequest`, `BacklogQueryResult`, and `BacklogItemReadModel` remain + the compatibility path for the existing simple backlog pane. +2. UI Plan 2 should add board-specific public read models instead of extending + `BacklogQueryResult`, because the board needs columns, summary + distributions, child previews, detail rows, and suggested slots while the + current list result intentionally exposes raw tasks plus staleness markers. +3. The board bucket policy belongs in pure scheduler backlog code under + `packages/scheduler_core/lib/src/scheduling/backlog/`, with Flutter consuming + only public `scheduler_core.dart` exports. + +## Block 02 Notes/Tags Breakpoint + +Adding real V1 task notes and freeform tags is a broad persistence change, not +only a drawer read-model change. It would touch at least: + +1. `Task`, `Task.quickCapture`, and `Task.copyWith`. +2. Task document fields and `TaskDocumentMapping`. +3. Persistence contract field sets and document field coverage tests. +4. In-memory application/repository round trips through the full domain object. +5. SQLite Drift task table columns, generated database code, schema versioning, + migration strategy, migration tests, and both SQLite task mappers. +6. Command/capture surfaces if the UI can create or edit notes/tags. + +Per `UI_PLAN_2_BLOCK_02_BACKLOG_READ_MODELS_AND_METADATA.md`, this is the +breakpoint where Ashley needs to choose whether drawer tags should be read-only +placeholders for now or true V1 metadata with a schema migration. + +Ashley chose the read-only path for UI Plan 2. Backlog drawer notes remain +`null`, and tags are derived only from existing narrow backlog metadata for now +(`BacklogTag.wishlist` displays as `someday`). True notes and freeform tags are +tracked in `Human Documentation/Things to consider todo/Backlog Notes and +Freeform Tags Metadata.md` for a later migration-backed feature. + +## Block 02 Board and Detail Queries + +1. `GetBacklogBoardRequest` and `GetBacklogTaskDetailRequest` now expose the + public query inputs needed by Flutter without importing scheduler `src/`. +2. `V1ApplicationManagementUseCases.getBacklogBoard` loads backlog candidates, + project display metadata, direct child previews, search/filter/sort/group + state, bucket policy results, and summary distributions through repository + read boundaries only. +3. `V1ApplicationManagementUseCases.getBacklogTaskDetail` returns the selected + backlog task drawer model, read-only derived tags, project options, and + non-mutating suggested slot previews. Missing duration returns a display + reason instead of attempting scheduling. +4. Focused application-management tests cover stable empty columns, bucket and + summary consistency, search/filter behavior, child previews, not-found detail + responses, read-only slot suggestions, and missing-duration handling. + +## Block 02 Demo Seed Data + +1. The Flutter demo composition now seeds Backlog board data as real scheduler + `Task` and `ProjectProfile` records, not widget-local mock rows. +2. The seed matches the feasible textual targets from Chunk 2.7: 24 backlog + tasks, project counts of Home 5 / Work 8 / Personal 5 / Learning 4 / Side + Project 2, priority counts of High 7 / Medium 9 / Low 8, and 12h 15m total + estimated time. +3. The plan/mockup contains a count conflict: the mockup summary says 24 tasks, + while the listed column badge counts add to 22. The current seed keeps 24 + repository-backed backlog tasks so the summary target stays truthful through + public read models. +4. `Review Q3 budget` is seeded with Work, 60 minutes, High priority, + medium reward, and easy difficulty. Finance/review freeform tags, notes text, + and exact drawer slot copy remain deferred with the approved metadata scope + decision above. + +## Block 03 Navigation and Screen Shell + +1. `FocusFlowSection` now owns app-level navigation state for Today, Backlog, + Projects, Reports, and Settings. The sidebar is controlled by this state and + exposes stable active keys for Today and Backlog tests. +2. `BacklogBoardController` loads `BacklogBoardQueryResult`, tracks local + search/filter/sort/group values, and keeps selected task detail as UI-local + state. It uses only public scheduler application read models. +3. Demo and persistent compositions both create the Backlog controller from the + same underlying application store used by Today, so future commands can + refresh both surfaces without creating separate fake state. +4. `BacklogBoardScreen` renders the desktop Backlog header, controls, column + shells, loading/empty/failure states, and a compact summary panel shell. + Detailed cards, charts, and drawer contents remain for later UI Plan 2 + blocks. + +## Block 04 Board Columns and Cards + +1. Backlog bucket, priority, project, reward, and future tag-chip colors now + live in `apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart` + instead of inline card mappings. +2. `BacklogBoardScreen` delegates column rendering to reusable board column and + task card widgets. The board keeps the summary panel fixed on the right and + horizontally scrolls the four-column board if the available desktop width is + constrained. +3. `BacklogTaskCard` renders only public `BacklogBoardItemReadModel` data: + title, subtitle or bucket reason, duration, project dot/name, priority flag, + difficulty bars, solid reward marks, selected outline, and direct child + previews. +4. Add-task controls intentionally call a no-op bucket callback until Block 8 + wires the create-task command flow. Card taps select through + `BacklogBoardController.selectTask`, which performs a read-only detail query. +5. The current demo seed still represents mockup child rows as normal backlog + tasks rather than parent-child relationships. Child preview rendering is + covered by a focused widget test using handcrafted public read models so the + seed scope does not expand in this block. + +## Block 05 Search, Filter, Sort, and Group Controls + +1. The board request now has `BacklogBoardFilterSelection` for board-only + filters: priority group, project, duration bucket, board bucket, and missing + duration. Legacy `BacklogFilter.noRewardSet` remains the cleanup filter for + no-reward tasks. +2. `BacklogSortKey.custom` preserves source/read-model order for the board + default, and `BacklogSortKey.duration` supports duration ordering. The simple + backlog list still keeps its existing priority default. +3. `BacklogBoardGroupMode` now supports project, priority, and duration + grouping as stable ordering inside the existing four bucket columns. The UI + does not add section dividers in this block. +4. The Backlog control row lives in + `apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart`. + Search reloads through the board query, filters apply through the public + request object, sort/group menus update controller state, and Compact, + Settings, and New Task remain safe inert shells until later blocks. + +## Block 06 Summary Panel + +1. `BacklogSummaryPanel` now owns the right-side summary surface and consumes + only `BacklogBoardSummaryReadModel` data from the public scheduler API. +2. The panel renders total tasks, total estimated time, priority distribution, + project distribution, a custom-painted duration donut with legend rows, and + the planning tip card without hardcoded summary values. +3. Summary panel color decisions use + `apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart` for + priority, project, and duration colors so board cards and summary rows share + the same visual mapping. +4. Collapse and expand state is local to the Flutter widget. It hides body + sections while leaving the summary header visible and does not write any + scheduler state. + +## Block 07 Task Detail Drawer + +1. `BacklogBoardController` now keeps selected-task id, detail loading state, + and detail-read failures as UI-local state. Detail failures render in the + drawer and no longer replace the whole Backlog screen. +2. `BacklogBoardScreen` renders `BacklogTaskDrawer` in a right-anchored + `Stack` overlay. The board and summary row stay in their original layout, so + opening and closing the drawer does not shift cards. +3. `BacklogTaskDrawer` renders header metadata, empty or read-only notes, + project display, read-only tags, duration/reward/difficulty metrics, + suggested slot previews, and Block-8-ready action buttons from public + `BacklogTaskDetailReadModel` data. +4. Real notes and freeform tags remain deferred per the Block 02 metadata + decision. The drawer shows calm empty states when the read model has no + notes or tags instead of inventing Flutter-only persisted metadata. + +## Block 08 Commands Through Drawer Actions + +1. Metadata edit controls remain safe non-mutating placeholders at this point + because real notes and freeform tags were explicitly deferred in Block 02. + Project and tag editing should be revisited with a migration-backed metadata + command rather than Flutter-only state. +2. `BacklogBoardScreen` now accepts the app `SchedulerCommandController`. + Backlog command state is rendered in a compact status banner below the + controls. +3. Top-level `New Task` and column `Add task` open a title-first creation + dialog. Successful submission calls the existing `quickCaptureToBacklog` + command and refreshes Backlog through the shared command controller. +4. Drawer `Schedule` calls the existing + `scheduleBacklogItemToNextAvailableSlot` command through + `SchedulerCommandController.scheduleBacklogItem`. Missing duration shows a + drawer-local message and does not start a command. +5. Drawer `Break Up` opens a focused child-entry dialog with two initial rows. + The dialog validates at least two titled rows, optional whole-minute + estimates, and submits `ApplicationChildTaskDraft` values through the + existing `breakUpTask` application command. Successful saves refresh the + board and selected detail so child previews come from scheduler read models. +6. Drawer `Push to Someday` now uses a Backlog-specific application command + that adds the existing `BacklogTag.wishlist` flag without scheduling or + deleting the task. The selected detail and board refresh into the `Not Now` + bucket. +7. Drawer `Remove` now confirms with the requested copy and archives the task + through a Backlog-specific application command. It marks the task + `TaskStatus.noLongerRelevant`, clears active Backlog selection on success, + and does not physically delete repository data. +8. The shared application-command change detector now includes Backlog metadata + such as `backlogTags`, so metadata-only commands are persisted even when the + command clock equals the task's previous `updatedAt`. + +## Block 09 Validation and Handoff + +1. Added final widget coverage for the default Backlog shell controls, empty + New Task title validation, and responsive no-overflow smoke checks at 1672, + 1440, 1280, and 1100 px desktop widths. +2. Added a SQLite schema-column regression test and kept Drift table DSL input + files out of the custom runtime coverage denominator. The fresh coverage + gate reports 80.34% package `lib/` line coverage. +3. Preserved the forbidden-import boundary test so Flutter widgets and + controllers continue to use public scheduler APIs instead of scheduler + internals, Drift, SQLite adapters, notification internals, backup/export + internals, or direct OS APIs outside approved runtime boundaries. +4. Final validation passed with: + - `scripts/test.sh` + - `cd apps/focus_flow_flutter && flutter analyze` + - `cd apps/focus_flow_flutter && flutter test` +5. Manual live UI review was not possible in this session because the local X11 + display was refusing new clients with `Maximum number of clients reached`. + The diagnosed cause was many duplicate `xbindkeys` clients, not a Flutter + build failure. +6. Deferred items remain limited to the approved metadata scope: real notes, + freeform tags, project edits, and the Backlog drawer `View day` shortcut. diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_SUMMARY.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_SUMMARY.md new file mode 100644 index 0000000..ac2b2ab --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/UI_PLAN_2_SUMMARY.md @@ -0,0 +1,345 @@ + + + +# UI Plan 2 — Backlog Board Tab Implementation + +**Status:** Proposed plan. +**Primary target:** Implement the finalized Backlog tab shown in the two supplied mockups. +**Default mockup:** `mockups/backlog_board_default.png`. +**Drawer mockup:** `mockups/backlog_task_drawer.png`. +**Execution mode:** Feed this folder to Codex and execute block files in numeric order. Use `xhigh` reasoning for all chunks unless Ashley explicitly says otherwise. +**Implementation branch suggestion:** `block-ui-backlog-board` or the repo’s closest existing branch convention. + +--- + +## 1. Purpose + +Build the desktop-first Backlog tab for FocusFlow as a real Flutter screen, not a throwaway static mockup. The final state should provide: + +1. Sidebar navigation with `Backlog` active. +2. Backlog title, task count, search, filters, sort, group, board/compact toggle, settings button, and `New Task` primary action. +3. Four-column backlog board: + - `Do Next` + - `Need Time Block` + - `Break Up First` + - `Not Now` +4. Backlog task cards with project, priority, duration, difficulty bars, reward icons, child-task previews, and selection styling. +5. Right-side Backlog summary panel in the default state. +6. Right-anchored task detail drawer when a backlog card is selected. +7. Suggested slots and task actions in the drawer. +8. New task / add task entry points wired to backlog-safe capture. +9. Tests and validation that preserve the existing backend-source-of-truth rule. + +This plan intentionally treats the mockups as finalized visual direction and uses the current repo architecture as implementation truth. + +--- + +## 2. Source truth and constraints + +Use sources in this order: + +1. Repo files, especially `AGENTS.md`, package boundaries, existing app structure, and tests. +2. This plan folder. +3. The two mockups in `mockups/`. +4. Existing completed UI Plan 1 files. +5. Human documentation and UX flow documents already in the repo. + +Hard constraints: + +- V1 is SQLite-first. Do not add MongoDB runtime work. +- `scheduler_core` remains pure Dart and is the scheduling/application source of truth. +- Flutter must not import scheduler `src/`, Drift/SQLite adapters, notification platform packages, backup/export internals, or OS APIs directly. +- UI may own local visual selection state, menu open/closed state, scroll position, text controller state, and drawer open/closed state. +- UI must not own canonical scheduling rules, placement rules, task lifecycle rules, or persistence mutations. +- Any scheduling/task mutation must go through public scheduler application commands/controllers. +- New Dart files require file-level Dartdoc library docs plus Dartdoc for every public/private class, enum, enum value, constructor, method, field, and top-level declaration, following `AGENTS.md`. +- New project files require SPDX metadata. + +--- + +## 3. Confirmed repo observations from this planning pass + +The supplied repo already contains: + +- Flutter app target: `apps/focus_flow_flutter/`. +- Current provisional/Plan 1 Today UI with sidebar, app shell, theme tokens, top bar, timeline cards, difficulty bars, reward icons, and selected-task modal pieces. +- Current simple `BacklogPane` and `BacklogRow` that are not visually aligned with the finalized board mockup. +- Existing public scheduler application/query concepts: + - `V1ApplicationManagementUseCases.getBacklog`. + - `BacklogQueryResult`. + - `BacklogItemReadModel`. + - `GetBacklogRequest`. + - `BacklogView`, `BacklogFilter`, and `BacklogSortKey`. + - `V1ApplicationCommandUseCases.quickCaptureToBacklog`. + - `V1ApplicationCommandUseCases.scheduleBacklogItemToNextAvailableSlot`. + - `V1ApplicationCommandUseCases.breakUpTask`. +- Existing domain `Task` supports core scheduling fields, project id, duration, priority, reward, difficulty, backlog tags, parent task id, stats, timestamps, scheduled/actual/completed intervals, status, and type. +- Current domain `Task` does **not** appear to include freeform notes or general tags, which the drawer mockup shows. +- Current application commands do **not** appear to include a general update/edit-backlog-task command, push-to-someday command, or remove/archive backlog command. + +Plan implication: this is a UI implementation plus a small application/domain read-model expansion, not only a widget rewrite. + +--- + +## 4. Product assumptions to implement unless Ashley changes them + +These assumptions unblock Codex without inventing broad product scope: + +1. **Backlog columns are a V1 read-model policy.** The Flutter board consumes a `BacklogBoardReadModel`; it does not independently decide canonical task grouping. +2. **Column assignment is deterministic and explainable.** Each item exposes `bucket`, `bucketReason`, and optional `reasonMetadata` so the UI can show why it is in that column. +3. **General tags and notes are V1 task metadata.** Add minimal domain/persistence/application support needed for the drawer and create/edit flows. +4. **Remove means archive from active backlog, not physical delete.** Use an application-level remove/archive transition that hides the task from backlog. Prefer `TaskStatus.noLongerRelevant` unless a repo-local convention already says otherwise. +5. **Push to Someday means mark the task as a someday/wishlist backlog item.** Use existing `BacklogTag.wishlist` where possible. This should move or keep the task in `Not Now`. +6. **Suggested slots are non-mutating previews.** They must be generated by application/core read logic or by calling scheduler logic against cloned/current state. They must not save anything until the user presses `Schedule`. +7. **Schedule button chooses a suggested slot only through backend intent.** If the current command only supports “next available slot,” wire the primary button to that command and label suggested rows as preview-only until a slot-specific command exists. +8. **Compact mode can remain a non-primary visual affordance.** Board mode is the acceptance target because only Board has a finalized mockup. If a low-risk compact list already exists, it may be reused behind the toggle, but do not invent a second finalized design. +9. **Settings remains visible and inert for this plan** unless a settings surface already exists. +10. **Project dropdown and tag controls in the drawer should support inline edits only after the relevant update command exists.** If the command is not completed in the same block, render them as disabled/read-only with active visual styling deferred only at the final polish pass. + +Non-blocking clarification items for Ashley after plan review: + +- Exact board bucket policy thresholds for `Do Next`, `Need Time Block`, `Break Up First`, and `Not Now`. +- Whether `Remove` should be reversible archive, cancellation, no-longer-relevant, or hard delete. +- Whether slot rows should support picking a specific slot in V1 or only preview the result of next-available scheduling. +- Whether freeform tags should be global reusable tags or simple task-local strings in V1. + +Do not stop implementation on those questions unless Ashley explicitly requests a policy change before coding. + +--- + +## 5. Visual target summary + +### App shell and sidebar + +- Keep the dark rounded app frame and left sidebar style from UI Plan 1. +- Sidebar width remains approximately 250 px. +- `Backlog` nav item is active with magenta-tinted fill and outline. +- `Today`, `Projects`, `Reports`, and `Settings` remain visible. +- Bottom-left status card shows: + - green check icon, + - `Schedule up to date`, + - `All changes are saved.` + +### Backlog default state + +At desktop width around 1672×941: + +- Main content has a left board region and a right summary panel. +- Header row: + - `Backlog` title, + - `24 tasks` count pill, + - subtitle: `Choose work that fits your current energy.` + - right controls: `Compact`, `Board` active, `Settings`. +- Control row: + - search field with placeholder `Search backlog...`, + - `Filters` button, + - `Sort: Custom` dropdown, + - `Group: None` dropdown, + - magenta `+ New Task` button with trailing chevron. +- Board columns: + - each column has an icon, title, count pill, subtitle, add icon, card stack, and `+ Add task` footer. +- Summary panel: + - `Backlog summary` header with sparkle icon and collapse chevron, + - total tasks and total estimated time, + - priority counts, + - project counts, + - duration distribution donut/list, + - planning tip card. + +### Selected-card drawer state + +- Selecting/clicking a backlog card opens a right-side drawer. +- Drawer is anchored to the right edge of the app content/window. +- Drawer overlays existing content and does **not** shift columns, header controls, or summary panel. +- No heavy scrim; underlying board remains visible. +- Selected card remains highlighted with magenta outline behind the drawer. +- Drawer width should be approximately 460–470 px at mockup width, clamped for smaller desktop windows. +- Drawer sections: + - header with bucket/status icon, title, sparkle icon, close icon, + - subtitle and age/created metadata, + - notes panel, + - project selector and tag chips, + - duration/reward/difficulty metric tiles, + - suggested slots list, + - action buttons: `Schedule`, `Break Up`, `Push to Someday`, `Remove`. + +--- + +## 6. Feature scope + +### In scope + +- Backlog route/tab under current Flutter app. +- Sidebar nav state and Backlog active rendering. +- Backlog-specific controllers/read state. +- Backlog board read models in public scheduler/application API. +- Demo seed data sufficient to render the exact mockup composition. +- Four board columns with counts and card ordering. +- Default summary panel. +- Drawer open/close/select-another behavior. +- Search/filter/sort/group controls with at least deterministic basic behavior. +- New Task and Add Task entry points with backlog-safe capture. +- Schedule action for a selected backlog item. +- Break Up action path if the existing `breakUpTask` command can be surfaced safely; otherwise the drawer button may open a non-persisting placeholder form until Block 8 completes the command wiring. +- Push to Someday command using `BacklogTag.wishlist` or equivalent. +- Remove/archive command using the chosen non-destructive lifecycle transition. +- Notes and tags support needed for drawer/create/edit read and persistence. +- Responsive handling for desktop width reductions. +- Widget tests and core/application tests. + +### Out of scope + +- Week/month views. +- Drag-and-drop card movement. +- Reports implementation. +- Shield/Recovery flows. +- Calendar sync. +- OS notifications. +- Backup/restore UI. +- Export/import UI. +- Full settings implementation. +- Perfect pixel golden tests. +- Mobile-first layout. + +--- + +## 7. Target data/read-model shape + +Codex may adjust exact names to match repo conventions, but preserve the information architecture. + +```dart +enum BacklogBoardBucket { + doNext, + needTimeBlock, + breakUpFirst, + notNow, +} + +class BacklogBoardQueryResult { + final String ownerId; + final CivilDate localDate; + final BacklogBoardSummaryReadModel summary; + final List columns; + final List projectOptions; +} + +class BacklogBoardColumnReadModel { + final BacklogBoardBucket bucket; + final String title; + final String subtitle; + final String accentToken; + final int count; + final List items; +} + +class BacklogBoardItemReadModel { + final String taskId; + final String title; + final String? subtitle; + final String projectId; + final String projectName; + final String projectColorToken; + final int? durationMinutes; + final PriorityLevel? priority; + final RewardLevel reward; + final DifficultyLevel difficulty; + final BacklogBoardBucket bucket; + final String bucketReason; + final DateTime createdAt; + final DateTime updatedAt; + final bool hasChildren; + final List childPreview; + final List tags; +} + +class BacklogTaskDetailReadModel { + final BacklogBoardItemReadModel item; + final String? notes; + final String addedLabel; + final List tags; + final List projectOptions; + final List suggestedSlots; +} +``` + +Summary should include: + +- total tasks, +- total estimated minutes, +- priority distribution, +- project distribution, +- duration distribution buckets: `0–30`, `30–60`, `60–120`, `120+`, +- planning tip string. + +Suggested slots should include: + +- stable id, +- local date label, +- start/end display text, +- duration minutes, +- fit label (`Great Fit`, `Okay`, no-fit/conflict if relevant), +- fit severity token, +- whether it is the primary/default scheduling target. + +--- + +## 8. Block list + +Execute these files in order: + +1. `UI_PLAN_2_BLOCK_01_REPO_SCOPE_AND_ASSETS.md` +2. `UI_PLAN_2_BLOCK_02_BACKLOG_READ_MODELS_AND_METADATA.md` +3. `UI_PLAN_2_BLOCK_03_NAVIGATION_AND_SCREEN_SHELL.md` +4. `UI_PLAN_2_BLOCK_04_BOARD_COLUMNS_AND_CARDS.md` +5. `UI_PLAN_2_BLOCK_05_SEARCH_FILTER_SORT_GROUP.md` +6. `UI_PLAN_2_BLOCK_06_SUMMARY_PANEL.md` +7. `UI_PLAN_2_BLOCK_07_TASK_DETAIL_DRAWER.md` +8. `UI_PLAN_2_BLOCK_08_COMMANDS_CREATE_AND_ACTIONS.md` +9. `UI_PLAN_2_BLOCK_09_TESTING_VALIDATION_HANDOFF.md` + +--- + +## 9. Expected final changed areas + +Likely changed or added areas: + +```text +packages/scheduler_core/lib/src/domain/models/entities/task.dart +packages/scheduler_core/lib/src/application/application_management/** +packages/scheduler_core/lib/src/application/application_commands/** +packages/scheduler_core/lib/src/persistence/document_mapping/tasks/** +packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/** +packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart +packages/scheduler_persistence_memory/** +packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/tasks/tasks.dart +packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/task_dao.dart +packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart +apps/focus_flow_flutter/lib/app/** +apps/focus_flow_flutter/lib/controllers/** +apps/focus_flow_flutter/lib/models/backlog/** +apps/focus_flow_flutter/lib/widgets/backlog/** +apps/focus_flow_flutter/lib/widgets/sidebar.dart +apps/focus_flow_flutter/lib/theme/** +apps/focus_flow_flutter/test/** +``` + +Do not modify unrelated packages unless tests prove a required boundary update. + +--- + +## 10. Final acceptance criteria + +The plan is complete when: + +- Backlog sidebar item opens/renders the Backlog tab and is visually active. +- Default Backlog board state closely matches `backlog_board_default.png`. +- Selecting `Review Q3 budget` or any task card opens a right-side drawer matching `backlog_task_drawer.png`. +- Drawer overlays content without moving columns or summary. +- Search/filter/sort/group controls are visible and do not break board state. +- Board columns, cards, summary, and drawer are all driven by scheduler/application read models or controller-level display models derived from those read models. +- Schedule action uses scheduler application command(s); UI does not compute canonical placement. +- New task capture creates backlog tasks through scheduler application command(s). +- Push to Someday and Remove are non-destructive domain/application commands, not Flutter-only state deletion. +- Tests cover default render, navigation, controls, card selection, drawer close, summary data, and command callbacks. +- `flutter analyze` and `flutter test` pass under `apps/focus_flow_flutter`. +- Root/package tests touched by domain/persistence changes pass or are explicitly reported if environment prevents full validation. diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/mockups/README.md b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/mockups/README.md new file mode 100644 index 0000000..139a780 --- /dev/null +++ b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/mockups/README.md @@ -0,0 +1,16 @@ + + + +# Backlog Board Mockups + +These images are the visual references for UI Plan 2. + +- `backlog_board_default.png` — finalized default Backlog board state with summary panel. +- `backlog_task_drawer.png` — finalized selected-task state with right-anchored drawer. + +Implementation notes: + +- Treat these as semi-strict visual references. +- Preserve the dark theme, board density, summary panel, and drawer anchoring. +- The drawer must overlay existing content and must not move board columns or summary layout. +- Do not introduce a white app background. diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/mockups/backlog_board_default.png b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/mockups/backlog_board_default.png new file mode 100644 index 0000000..355d6f4 Binary files /dev/null and b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/mockups/backlog_board_default.png differ diff --git a/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/mockups/backlog_task_drawer.png b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/mockups/backlog_task_drawer.png new file mode 100644 index 0000000..af6b7db Binary files /dev/null and b/Codex Documentation/Completed Plans/UI Plan 2 - Backlog Board Implementation/mockups/backlog_task_drawer.png differ diff --git a/Codex Documentation/Completed Plans/V1_ADR_002_SQLite_Document_Schema_V1.md b/Codex Documentation/Completed Plans/V1_ADR_002_SQLite_Document_Schema_V1.md index d5d918d..f41bed9 100644 --- a/Codex Documentation/Completed Plans/V1_ADR_002_SQLite_Document_Schema_V1.md +++ b/Codex Documentation/Completed Plans/V1_ADR_002_SQLite_Document_Schema_V1.md @@ -1,35 +1,59 @@ -# V1 ADR 002: SQLite Document Schema V1 + + + +# V1 ADR 002: SQLite Document Schema V2 + +Status: **Accepted; updated to match current app state** -Status: **Accepted** Date: 2026-06-26 +Last reviewed: 2026-07-07 + ## Context -The project pivoted from MongoDB to **SQLite‑first** persistence. -We need a stable, versioned relational schema that maps cleanly to the domain -objects while staying behind the repository abstraction. Drift will manage -migrations. + +The project is SQLite-first. The local app persists scheduler state in a Drift +database and keeps storage details behind repository and application-unit-of-work +boundaries. + +The original V1 schema was implemented and then migrated to the current Drift +schema version 2 when application-layer records became durable state. ## Decision -* **Database file**: `adhd_scheduler.sqlite` in the user data directory. -* **Schema version**: `1` (managed by Drift). + +* **Database file**: `scheduler.sqlite` under `~/ADHD_Scheduler/` by default. + Runtime composition may override this with `SCHEDULER_SQLITE_PATH`, and dev + tooling may pass `--sqlite`. +* **Schema version**: `2`, managed by Drift. Version 1 files are supported as + migration inputs; downgrades are not supported. * **Tables** | Table | Purpose | Key fields | |-------|---------|------------| -| `tasks` | Authoritative task rows | `id TEXT PRIMARY KEY`, `owner_id TEXT`, `project_id`, `parent_id`, `type`, `status`, `priority`, `reward`, `difficulty`, `scheduled_start_utc`, `scheduled_end_utc`, `actual_start_utc`, `actual_end_utc`, `completed_at_utc`, `revision INT`, `created_at_utc`, `updated_at_utc`, `backlog_entered_at_utc`, `backlog_entered_provenance TEXT` | -| `projects` | Project configuration | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `color_key`, config defaults …, `archived_at_utc`, `revision` | -| `locked_blocks` | Recurring / one‑off locked time | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `date TEXT`, `start_time TEXT`, `end_time TEXT`, `recurrence_json`, `hidden_by_default INT`, `archived_at_utc`, `revision` | -| `locked_overrides` | Date‑scoped overrides | `id TEXT PRIMARY KEY`, `owner_id`, `locked_block_id`, `date TEXT`, `type`, JSON fields | -| `settings` | One row per owner | `owner_id TEXT PRIMARY KEY`, `timezone_id`, `day_start_minutes`, `day_end_minutes`, `compact_mode INT`, `revision` | -| `activities` | Append‑only internal events | `id TEXT PRIMARY KEY`, `owner_id`, `task_id`, `code`, `occurred_at_utc`, `metadata_json` | -| `snapshots` | Bounded diagnostic schedule snapshots | `id TEXT PRIMARY KEY`, `owner_id`, `operation_name`, `source_date TEXT`, `window_json`, `notice_json`, `changes_json`, `retention_expires_utc` | +| `tasks` | Authoritative task rows | `id TEXT PRIMARY KEY`, `owner_id`, `title`, `project_id`, `parent_id`, `type`, `status`, `priority`, `reward`, `difficulty`, `duration_minutes`, scheduled/actual/completed UTC timestamps, `backlog_tags_json`, `reminder_override`, `stats_json`, `backlog_entered_at_utc`, `backlog_entered_provenance`, `revision`, `created_at_utc`, `updated_at_utc` | +| `task_activities` | Append-only task activity facts | `id TEXT PRIMARY KEY`, `owner_id`, `task_id`, `project_id`, `operation_id`, `code`, `occurred_at_utc`, `metadata_json` | +| `projects` | Project configuration | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `color_key`, configured defaults, `archived_at_utc`, `revision`, `created_at_utc`, `updated_at_utc` | +| `project_statistics` | Project-level aggregate statistics | `project_id PRIMARY KEY`, `owner_id`, completion and suggestion-count fields, `revision`, `created_at_utc`, `updated_at_utc` | +| `locked_blocks` | Recurring / one-off locked time | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `date`, `start_time`, `end_time`, `recurrence_json`, `hidden_by_default`, `project_id`, `archived_at_utc`, `revision`, timestamps | +| `locked_overrides` | Date-scoped locked-block overrides | `id TEXT PRIMARY KEY`, `owner_id`, `locked_block_id`, `date`, override type and payload fields | +| `settings` | One row per owner | `owner_id TEXT PRIMARY KEY`, `timezone_id`, `day_start_minutes`, `day_end_minutes`, `compact_mode`, `backlog_staleness_json`, `revision`, timestamps | +| `snapshots` | Bounded diagnostic schedule snapshots | `id TEXT PRIMARY KEY`, `owner_id`, `captured_at_utc`, `operation_name`, `source_date`, `target_date`, JSON payload columns, `retention_expires_utc`, `truncated`, `revision`, timestamps | +| `application_operations` | Committed operation records for idempotency | `operation_id TEXT PRIMARY KEY`, `owner_id`, `operation_name`, `committed_at_utc` | +| `notice_acknowledgements` | Owner-scoped acknowledged notice records | composite key `owner_id, notice_id`, `acknowledged_at_utc`, `revision`, timestamps | -* All timestamps are stored as **UTC**. -* Optimistic concurrency: every mutable row carries `revision`. Updates must - include `WHERE revision = :expected` and increment on success. -* Drift migrations are generated and tested; downgrades are not supported. +* UTC timestamps are stored in Drift `DateTime` columns. Civil dates and wall + times remain text fields where the domain model requires calendar-local values. +* Mutable domain/application records carry optimistic `revision` values. Updates + must compare the expected revision and increment on success. Append-only + activity and operation records are immutable facts and do not use mutable-save + revision checks. +* Schema version 2 migrations create the application-layer tables and indexes + needed for task activity, project statistics, idempotent operations, and notice + acknowledgements. ## Consequences -* Domain objects remain unchanged; adapters translate. -* Backup library (Block 24) can copy and encrypt this single file. -* Repository contract tests assert schema integrity via Drift introspection. \ No newline at end of file + +* Domain objects remain unchanged; adapters translate between domain objects and + SQLite rows. +* The backup library copies and encrypts this single SQLite file. +* Repository conformance tests cover adapter behavior, while Drift schema tests + verify version 2 tables and version 1 upgrade behavior. diff --git a/Codex Documentation/Completed Plans/V1_ADR_005_Export_Backup_Boundary.md b/Codex Documentation/Completed Plans/V1_ADR_005_Export_Backup_Boundary.md index d469568..eae9fd6 100644 --- a/Codex Documentation/Completed Plans/V1_ADR_005_Export_Backup_Boundary.md +++ b/Codex Documentation/Completed Plans/V1_ADR_005_Export_Backup_Boundary.md @@ -1,9 +1,33 @@ + + + # V1 ADR 005: Export & Backup Boundary -Status: **Accepted** +Status: **Accepted; updated to match current app state** + Date: 2026-06-26 -* **Backup** – passphrase‑encrypted SQLite copy (`.sqlite.aes` via AES‑256‑GCM, PBKDF2 200 k rounds). -* **Readable export** – JSON and CSV via `ExportController`. +Last reviewed: 2026-07-07 + +## Decision + +* **Backup**: passphrase-encrypted copy of the local SQLite database bytes. + Defaults: + * source DB: `~/ADHD_Scheduler/scheduler.sqlite` + * backup directory: `~/ADHD_Scheduler/backups` + * backup filename: `yyyymmdd_hhmm.db.enc` +* **Backup cryptography**: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation, + 200,000 iterations, random salt, and random nonce. The encoded file is a JSON + envelope containing format metadata, salt, nonce, ciphertext, and MAC. +* **Restore**: decrypts a `.db.enc` backup and atomically replaces the target + SQLite file. +* **Readable export**: JSON and CSV are produced through `ExportController` and + export writer implementations. + +## Boundary + +Encrypted backup is the restorable binary preservation path for the SQLite +runtime database. JSON and CSV exports are readable user/data-portability outputs +and are not the canonical restore format. No open questions remain. diff --git a/Codex Documentation/Completed Plans/V1_ADR_006_Schedule_Snapshot_Policy.md b/Codex Documentation/Completed Plans/V1_ADR_006_Schedule_Snapshot_Policy.md index cb33034..edbb9c8 100644 --- a/Codex Documentation/Completed Plans/V1_ADR_006_Schedule_Snapshot_Policy.md +++ b/Codex Documentation/Completed Plans/V1_ADR_006_Schedule_Snapshot_Policy.md @@ -1,8 +1,32 @@ + + + # V1 ADR 006: Schedule Snapshot Policy -Status: **Accepted** +Status: **Accepted; updated to match current app state** + Date: 2026-06-26 -Persist committed diagnostic snapshots only; bounded retention 30 days or until notices acknowledged. +Last reviewed: 2026-07-07 -No open questions remain. +## Decision + +Persist committed diagnostic schedule snapshots only. Snapshots are diagnostic +records, not authoritative task state. + +Snapshot retention is bounded by a `retention_expires_utc` timestamp derived from +the snapshot capture time plus 30 days. Repository cleanup deletes snapshots whose +retention expiry is before the cleanup instant. + +Notice acknowledgement is stored separately in `notice_acknowledgements`. The +Today read model suppresses acknowledged notices when presenting pending notice +state, but acknowledgement does not delete the source snapshot early. + +## Consequences + +* Snapshot rows can remain available for diagnostics until their retention + expiry even after all displayed notices have been acknowledged. +* Notice acknowledgement is an application-layer read-model concern, not a + snapshot lifecycle rule. +* Cleanup jobs and tests should target `retention_expires_utc`, not notice + acknowledgement status. diff --git a/Codex Documentation/Completed Plans/V1_ADR_008_Notification_Abstraction.md b/Codex Documentation/Completed Plans/V1_ADR_008_Notification_Abstraction.md index 4a7afbe..1f254b7 100644 --- a/Codex Documentation/Completed Plans/V1_ADR_008_Notification_Abstraction.md +++ b/Codex Documentation/Completed Plans/V1_ADR_008_Notification_Abstraction.md @@ -1,8 +1,43 @@ + + + # V1 ADR 008: Notification Abstraction -Status: **Accepted** +Status: **Accepted; updated to match current app state** + Date: 2026-06-26 -`NotificationAdapter` interface abstracts scheduling/cancellation and feedback stream. Desktop implementation in `scheduler_notifications_desktop`; Fake adapter for tests. +Last reviewed: 2026-07-07 -No open questions remain. +## Decision + +`NotificationAdapter` in `scheduler_notifications` is the platform-neutral +boundary for reminder delivery. It exposes: + +* `schedule(NotificationRequest request)` +* `cancel(String id)` +* `Stream feedback` + +The pure scheduler core computes reminder policy and directive values only. It +does not import desktop, OS, process, or notification APIs. + +The test fake, `FakeNotificationAdapter`, stores scheduled requests, +normalizes/captures cancellations, and can emit feedback events for workflow +tests. + +The desktop implementation lives in `scheduler_notifications_desktop`. +It delegates to platform backends: + +* Linux: `notify-send` +* macOS: `osascript` +* Windows and unsupported platforms: stdout fallback + +The current desktop adapter satisfies the feedback-stream contract with an empty +stream. OS-level click/action feedback is not implemented in V1. + +## Consequences + +* Reminder delivery remains replaceable at the composition boundary. +* Core tests can use the fake adapter without depending on platform APIs. +* Future OS feedback support must extend the desktop package without changing + the scheduler core boundary. diff --git a/Human Documentation/FocusFlow_UX_Interaction_Flows_Dark.pptx b/Human Documentation/FocusFlow_UX_Interaction_Flows_Dark.pptx deleted file mode 100644 index 42dcd1f..0000000 Binary files a/Human Documentation/FocusFlow_UX_Interaction_Flows_Dark.pptx and /dev/null differ diff --git a/Human Documentation/High Level/FocusFlow_API_Developer_Guide.odt b/Human Documentation/High Level/FocusFlow_API_Developer_Guide.odt new file mode 100644 index 0000000..1622ccb Binary files /dev/null and b/Human Documentation/High Level/FocusFlow_API_Developer_Guide.odt differ diff --git a/Human Documentation/High Level/FocusFlow_New_Programmer_Startup_Guide.odt b/Human Documentation/High Level/FocusFlow_New_Programmer_Startup_Guide.odt new file mode 100644 index 0000000..2042d35 Binary files /dev/null and b/Human Documentation/High Level/FocusFlow_New_Programmer_Startup_Guide.odt differ diff --git a/Human Documentation/High Level/FocusFlow_UI_Feature_Mapping.odp b/Human Documentation/High Level/FocusFlow_UI_Feature_Mapping.odp new file mode 100644 index 0000000..66b9b00 Binary files /dev/null and b/Human Documentation/High Level/FocusFlow_UI_Feature_Mapping.odp differ diff --git a/Human Documentation/High Level/FocusFlow_V1_Requirements_Spec.docx b/Human Documentation/High Level/FocusFlow_V1_Requirements_Spec.docx new file mode 100644 index 0000000..8dfd045 Binary files /dev/null and b/Human Documentation/High Level/FocusFlow_V1_Requirements_Spec.docx differ diff --git a/Human Documentation/Overall App Design Spec.docx b/Human Documentation/High Level/Overall App Design Spec.docx similarity index 100% rename from Human Documentation/Overall App Design Spec.docx rename to Human Documentation/High Level/Overall App Design Spec.docx diff --git a/Human Documentation/FocusFlow_UX_Interaction_Flows_Dark.pdf b/Human Documentation/High Level/draft_0.1.0_UX_Interaction_Flows.pdf similarity index 100% rename from Human Documentation/FocusFlow_UX_Interaction_Flows_Dark.pdf rename to Human Documentation/High Level/draft_0.1.0_UX_Interaction_Flows.pdf diff --git a/Human Documentation/Original Chat-Compiled Design Spec.md b/Human Documentation/Original Chat-Compiled Design Spec.md deleted file mode 100644 index 62a54aa..0000000 --- a/Human Documentation/Original Chat-Compiled Design Spec.md +++ /dev/null @@ -1,13 +0,0 @@ -# ADHD-Friendly Scheduling Application - -Unified Product and Design Specification - -Draft compiled from user requirements - June 19, 2026 - -## Executive Summary - -This application is a failure-tolerant scheduling system for people who struggle with executive dysfunction, task migration, reminders, volatile schedules, and missed logging. It is not intended to be a generic productivity app. Its core job is to preserve intent when the plan breaks: move flexible tasks forward, protect required commitments, keep locked unavailable time out of the way visually, and make recovery simple instead of punitive. - -The MVP focuses on Today, Backlog, recurring hidden locked blocks, push/rollover behavior, task capture, and simple state management. Version 2.0 adds week/month views, reports, drag-and-drop, task history, and the full overwhelm shield/recovery system. - -See the DOCX file for the complete formatted specification. diff --git a/Human Documentation/README.md b/Human Documentation/README.md deleted file mode 100644 index 6ec5411..0000000 --- a/Human Documentation/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Human Documentation - -This folder contains human-facing product documentation. - -Start with: - -- `Overall App Design Spec.docx` -- `Original Chat-Compiled Design Spec.md` - -These documents define the product intent, terminology, major features, and version boundaries. Codex should use these files for product context but should execute from the files in `Codex Documentation/Current Software Plan/`. diff --git a/Human Documentation/Things to consider todo/Backlog Notes and Freeform Tags Metadata.md b/Human Documentation/Things to consider todo/Backlog Notes and Freeform Tags Metadata.md new file mode 100644 index 0000000..24be5ec --- /dev/null +++ b/Human Documentation/Things to consider todo/Backlog Notes and Freeform Tags Metadata.md @@ -0,0 +1,64 @@ + + + +# Backlog Notes and Freeform Tags Metadata + +## Status + +Deferred from UI Plan 2 on 2026-07-07. + +For the current Backlog board implementation, notes and freeform tags are +read-only presentation placeholders. Do not expand the current UI Plan 2 scope +to add persisted task notes or general-purpose tags. + +## Why this is deferred + +The drawer mockup includes notes and tag chips, but the current V1 `Task` domain +model only has narrow `BacklogTag` flags such as `wishlist`. It does not yet +have general freeform notes or user-defined task tags. + +Adding those fields correctly is not a local widget change. It is a real V1 +metadata and persistence feature with schema, migration, command, and test +impact. + +## Work left for the future feature + +When this feature is resumed, decide the exact product behavior first: + +1. Whether notes are plain text only or support lightweight formatting later. +2. Whether tags are task-local strings or reusable global tag records. +3. Maximum note length. +4. Maximum tag count per task. +5. Maximum tag label length. +6. Case and duplicate handling for tags. +7. Whether tag edits are allowed in quick capture, the Backlog drawer, or both. +8. Whether existing imports/exports should include notes and tags immediately. + +Expected implementation areas: + +1. Add `String? notes` and either `Set tags` or a small validated tag + value object to `Task`. +2. Update `Task.quickCapture`, `Task.copyWith`, and domain invariant tests. +3. Add validation and normalization for blank notes, blank tags, duplicate tags, + and safe payload limits. +4. Extend task document fields and `TaskDocumentMapping`. +5. Update document mapping, migration, and persistence field coverage tests. +6. Update repository conformance expectations so notes/tags round-trip through + every adapter. +7. Add SQLite Drift task columns or a normalized tag table, depending on the + chosen tag model. +8. Bump or migrate SQLite schema as required and add migration tests for old + databases. +9. Regenerate Drift code. +10. Update SQLite application and repository task row mappers. +11. Extend quick-capture/create/update command surfaces if users can write notes + or tags. +12. Update readable export/import behavior if these fields are part of user data + portability. + +## Current UI Plan 2 handling + +For UI Plan 2, the Backlog board and drawer may display placeholder or derived +read-only tag text where useful, but Flutter must not pretend those values are +persisted editable task metadata. Editing controls should remain disabled, +hidden, or clearly non-editing until the future metadata feature lands. diff --git a/Human Documentation/Starter Architecture Notes.md b/Human Documentation/Things to consider todo/Starter Architecture Notes.md similarity index 100% rename from Human Documentation/Starter Architecture Notes.md rename to Human Documentation/Things to consider todo/Starter Architecture Notes.md diff --git a/Human Documentation/Unified Product Design Summary.md b/Human Documentation/archive/Unified Product Design Summary.md similarity index 100% rename from Human Documentation/Unified Product Design Summary.md rename to Human Documentation/archive/Unified Product Design Summary.md diff --git a/apps/focus_flow_flutter/README.md b/apps/focus_flow_flutter/README.md index 427c026..f8bc852 100644 --- a/apps/focus_flow_flutter/README.md +++ b/apps/focus_flow_flutter/README.md @@ -11,10 +11,19 @@ The app launches into the compact Today timeline. Normal startup now opens an on-disk SQLite database and bootstraps owner settings plus the default Home project when they are missing. -The compact Today screen can toggle completion for currently rendered task -cards. Backlog widgets and quick-capture controls exist for tests and focused -composition paths, but the sidebar Backlog navigation remains out of scope for -this screen. +cards. The sidebar Backlog navigation opens the board surface backed by public +scheduler application APIs. Backlog supports the four board buckets, search, +filters, sort/group controls, summary data, the right-anchored detail drawer, +task creation, scheduling, Break Up child-task creation, Someday tagging, and +non-destructive archive/remove from active Backlog. + +Backlog notes, project edits, and freeform tag edits intentionally remain +read-only or non-mutating placeholders until the deferred metadata migration and +application command are implemented. + +The sidebar Settings item and top bar Settings button both open a dialog bound +to persisted `OwnerSettings`/`BacklogStalenessSettings`, covering time zone, day +window, compact-mode toggle, and backlog staleness thresholds. ## Runtime Data @@ -102,13 +111,11 @@ boundary. ## Intentionally No-op -- Sidebar navigation. -- Date navigation and calendar buttons. -- Compact/Normal toggle. -- Settings button. +- Compact/Normal toggle (Settings persists the flag, but the Today pipeline + does not yet read it; see Forgejo issue #11). - Show upcoming. -- Timeline card action icons. -- Modal Push, Move to Backlog, and Break up buttons. +- Timeline card Schedule and Protect time icons. +- Backlog drawer project selector, add-tag control, and View day shortcut. ## Persistence Proof @@ -171,7 +178,8 @@ Run these from `apps/focus_flow_flutter/`. ## Next Plans -- Wire visible Backlog navigation and quick-capture UI into the main shell. -- Wire functional Push, Move to Backlog, and Break up actions. -- Add real navigation/settings. +- Give the Compact/Normal toggle real backing state (the live Today pipeline + has no compact-mode concept yet; see Forgejo issue #11). +- Add migration-backed notes, project edits, and freeform tags for Backlog + metadata. - Add Shield/Recovery only in a later scope. diff --git a/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart b/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart index 6aadc5d..85255c6 100644 --- a/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart +++ b/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart @@ -3,6 +3,30 @@ part of '../demo_scheduler_composition.dart'; +/// Top-level helper that performs the `_demoProjects` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _demoProjects() { + return [ + ProjectProfile(id: 'home', name: 'Home', colorKey: 'project-home'), + ProjectProfile(id: 'work', name: 'Work', colorKey: 'project-work'), + ProjectProfile( + id: 'personal', + name: 'Personal', + colorKey: 'project-personal', + ), + ProjectProfile( + id: 'learning', + name: 'Learning', + colorKey: 'project-learning', + ), + ProjectProfile( + id: 'side-project', + name: 'Side Project', + colorKey: 'project-side-project', + ), + ]; +} + /// Top-level helper that performs the `_todaySeedTasks` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. List _todaySeedTasks(CivilDate date, DateTime createdAt) { @@ -123,6 +147,284 @@ List _todaySeedTasks(CivilDate date, DateTime createdAt) { ]; } +/// Top-level helper that performs the `_backlogSeedTasks` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +List _backlogSeedTasks(CivilDate date, DateTime createdAt) { + return [ + _backlogSeedTask( + id: 'reply-to-3-emails', + title: 'Reply to 3 emails', + projectId: 'home', + order: 1, + priority: PriorityLevel.medium, + reward: RewardLevel.low, + difficulty: DifficultyLevel.easy, + durationMinutes: 10, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'update-project-status', + title: 'Update project status', + projectId: 'work', + order: 2, + priority: PriorityLevel.medium, + reward: RewardLevel.low, + difficulty: DifficultyLevel.easy, + durationMinutes: 15, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'schedule-dentist-appt', + title: 'Schedule dentist appt', + projectId: 'personal', + order: 3, + priority: PriorityLevel.medium, + reward: RewardLevel.low, + difficulty: DifficultyLevel.easy, + durationMinutes: 10, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'clean-up-downloads-folder', + title: 'Clean up downloads folder', + projectId: 'home', + order: 4, + priority: PriorityLevel.medium, + reward: RewardLevel.low, + difficulty: DifficultyLevel.easy, + durationMinutes: 10, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'plan-tomorrow', + title: 'Plan tomorrow', + projectId: 'work', + order: 5, + priority: PriorityLevel.medium, + reward: RewardLevel.low, + difficulty: DifficultyLevel.easy, + durationMinutes: 10, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'review-q3-budget', + title: 'Review Q3 budget', + projectId: 'work', + order: 6, + priority: PriorityLevel.high, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + durationMinutes: 60, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'plan-next-week', + title: 'Plan next week', + projectId: 'work', + order: 7, + priority: PriorityLevel.high, + reward: RewardLevel.high, + difficulty: DifficultyLevel.medium, + durationMinutes: 30, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'define-weekly-goals', + title: 'Define weekly goals', + projectId: 'work', + order: 8, + priority: PriorityLevel.high, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + durationMinutes: 15, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'time-block-schedule', + title: 'Time block schedule', + projectId: 'work', + order: 9, + priority: PriorityLevel.high, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + durationMinutes: 15, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'organize-kitchen-drawers', + title: 'Organize kitchen drawers', + projectId: 'home', + order: 10, + priority: PriorityLevel.low, + reward: RewardLevel.low, + difficulty: DifficultyLevel.medium, + durationMinutes: 45, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'renew-car-registration', + title: 'Renew car registration', + projectId: 'home', + order: 11, + priority: PriorityLevel.high, + reward: RewardLevel.low, + difficulty: DifficultyLevel.medium, + durationMinutes: 25, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'build-analytics-dashboard', + title: 'Build analytics dashboard', + projectId: 'work', + order: 12, + priority: PriorityLevel.high, + reward: RewardLevel.high, + difficulty: DifficultyLevel.hard, + durationMinutes: 120, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'write-marketing-strategy', + title: 'Write marketing strategy', + projectId: 'work', + order: 13, + priority: PriorityLevel.high, + reward: RewardLevel.high, + difficulty: DifficultyLevel.medium, + durationMinutes: 90, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'complete-online-course', + title: 'Complete online course', + projectId: 'learning', + order: 14, + priority: PriorityLevel.medium, + reward: RewardLevel.high, + difficulty: DifficultyLevel.medium, + durationMinutes: 90, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'module-1-foundations', + title: 'Module 1: Foundations', + projectId: 'learning', + order: 15, + priority: PriorityLevel.medium, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.hard, + durationMinutes: 15, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'module-2-deep-dive', + title: 'Module 2: Deep Dive', + projectId: 'learning', + order: 16, + priority: PriorityLevel.low, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.hard, + durationMinutes: 15, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'module-3-practice', + title: 'Module 3: Practice', + projectId: 'learning', + order: 17, + priority: PriorityLevel.low, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.hard, + durationMinutes: 15, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'learn-guitar', + title: 'Learn guitar', + projectId: 'personal', + order: 18, + priority: PriorityLevel.low, + reward: RewardLevel.low, + difficulty: DifficultyLevel.medium, + durationMinutes: 30, + backlogTags: const {BacklogTag.wishlist}, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'start-youtube-channel', + title: 'Start YouTube channel', + projectId: 'side-project', + order: 19, + priority: PriorityLevel.low, + reward: RewardLevel.low, + difficulty: DifficultyLevel.medium, + durationMinutes: 20, + backlogTags: const {BacklogTag.wishlist}, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'travel-to-japan', + title: 'Travel to Japan', + projectId: 'personal', + order: 20, + priority: PriorityLevel.medium, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + durationMinutes: 30, + backlogTags: const {BacklogTag.wishlist}, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'home-office-upgrade', + title: 'Home office upgrade', + projectId: 'home', + order: 21, + priority: PriorityLevel.low, + reward: RewardLevel.low, + difficulty: DifficultyLevel.medium, + durationMinutes: 20, + backlogTags: const {BacklogTag.wishlist}, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'read-2-books', + title: 'Read 2 books', + projectId: 'personal', + order: 22, + priority: PriorityLevel.low, + reward: RewardLevel.low, + difficulty: DifficultyLevel.medium, + durationMinutes: 15, + backlogTags: const {BacklogTag.wishlist}, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'sketch-app-icon-ideas', + title: 'Sketch app icon ideas', + projectId: 'side-project', + order: 23, + priority: PriorityLevel.medium, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + durationMinutes: 15, + backlogTags: const {BacklogTag.wishlist}, + createdAt: createdAt, + ), + _backlogSeedTask( + id: 'archive-photo-backlog', + title: 'Archive photo backlog', + projectId: 'personal', + order: 24, + priority: PriorityLevel.low, + reward: RewardLevel.low, + difficulty: DifficultyLevel.medium, + durationMinutes: 15, + backlogTags: const {BacklogTag.wishlist}, + createdAt: createdAt, + ), + ]; +} + /// Top-level helper that performs the `_seedTask` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Task _seedTask({ @@ -161,3 +463,34 @@ Task _seedTask({ updatedAt: completedAt ?? createdAt, ); } + +/// Top-level helper that performs the `_backlogSeedTask` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Task _backlogSeedTask({ + required String id, + required String title, + required String projectId, + required int order, + required PriorityLevel priority, + required RewardLevel reward, + required DifficultyLevel difficulty, + required int durationMinutes, + required DateTime createdAt, + Set backlogTags = const {}, +}) { + final orderedCreatedAt = createdAt.add(Duration(minutes: order)); + return Task( + id: id, + title: title, + projectId: projectId, + type: TaskType.flexible, + status: TaskStatus.backlog, + priority: priority, + reward: reward, + difficulty: difficulty, + durationMinutes: durationMinutes, + backlogTags: backlogTags, + createdAt: orderedCreatedAt, + updatedAt: orderedCreatedAt, + ); +} diff --git a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index 60a5a51..21b6a01 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -6,6 +6,7 @@ library; import 'package:scheduler_core/scheduler_core.dart'; +import '../controllers/backlog_board_controller.dart'; import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_read_controller.dart'; import '../controllers/selected_date_controller.dart'; @@ -91,24 +92,13 @@ class DemoSchedulerComposition implements SchedulerAppComposition { CivilDate.fromDateTime((clock ?? const SystemClock()).now()); final readAt = _instant(resolvedDate, 15, 55); final createdAt = _instant(resolvedDate, 8); - final project = ProjectProfile( - id: projectId, - name: 'Home', - colorKey: 'project-home', - ); + final projects = _demoProjects(); final tasks = [ if (includeTodayItems) ..._todaySeedTasks(resolvedDate, createdAt), - if (includeBacklogItems) - Task.quickCapture( - id: 'later-backlog-note', - title: 'Review reusable grocery list', - projectId: projectId, - createdAt: createdAt, - updatedAt: createdAt, - ), + if (includeBacklogItems) ..._backlogSeedTasks(resolvedDate, createdAt), ]; final store = InMemoryApplicationUnitOfWork( - initialProjects: [project], + initialProjects: projects, initialOwnerSettings: [ OwnerSettings( ownerId: ownerId, @@ -158,6 +148,39 @@ class DemoSchedulerComposition implements SchedulerAppComposition { ); } + /// Creates the controller used by the Backlog board screen. + @override + BacklogBoardController createBacklogBoardController({ + required SelectedDateController selectedDates, + }) { + return BacklogBoardController( + selectedDates: selectedDates, + dateLabelFor: dateLabelFor, + readBoard: (request) { + return managementUseCases.getBacklogBoard( + GetBacklogBoardRequest( + context: context('ui-read-backlog-board'), + localDate: request.localDate, + searchText: request.searchText, + filters: request.filters, + boardFilters: request.boardFilters, + sortKey: request.sortKey, + groupMode: request.groupMode, + ), + ); + }, + readTaskDetail: (taskId, localDate) { + return managementUseCases.getBacklogTaskDetail( + GetBacklogTaskDetailRequest( + context: context('ui-read-backlog-detail'), + localDate: localDate, + taskId: taskId, + ), + ); + }, + ); + } + /// Creates a generic read controller for Today-state panes. UiReadController createTodayController() { return ApplicationReadController( @@ -176,18 +199,16 @@ class DemoSchedulerComposition implements SchedulerAppComposition { ); } - /// Creates a generic read controller for backlog panes. - UiReadController createBacklogController() { - return ApplicationReadController( + /// Creates a generic read controller for the settings screen. + @override + UiReadController createOwnerSettingsController() { + return ApplicationReadController( read: () { - return managementUseCases.getBacklog( - GetBacklogRequest( - context: context('ui-read-backlog'), - sortKey: BacklogSortKey.age, - ), + return managementUseCases.getOwnerSettings( + GetOwnerSettingsRequest(context: context('ui-read-owner-settings')), ); }, - isEmpty: (state) => state.items.isEmpty, + isEmpty: (_) => false, ); } @@ -199,6 +220,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition { }) { return SchedulerCommandController( commands: commandUseCases, + management: managementUseCases, contextFor: context, selectedDate: selectedDate, refreshReads: refreshReads, diff --git a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart index 979169f..11ecede 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -9,13 +9,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:scheduler_core/scheduler_core.dart'; +import '../controllers/backlog_board_controller.dart'; import '../controllers/scheduler_command_controller.dart'; +import '../controllers/scheduler_read_controller.dart'; import '../controllers/selected_date_controller.dart'; import '../controllers/today_screen_controller.dart'; +import '../models/navigation/focus_flow_section.dart'; import '../models/today_screen_models.dart'; import '../theme/focus_flow_theme.dart'; import '../theme/focus_flow_tokens.dart'; import '../widgets/app_shell.dart'; +import '../widgets/backlog/backlog_board_screen.dart'; +import '../widgets/dialogs/settings_dialog.dart'; import '../widgets/required_banner.dart'; import '../widgets/sidebar.dart'; import '../widgets/task_selection_modal.dart'; diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart index 15c23d8..c12b66e 100644 --- a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -6,9 +6,13 @@ part of '../focus_flow_app.dart'; /// Private implementation type for `_FocusFlowHomeState` in this library. /// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. class _FocusFlowHomeState extends State { - /// Stores the `controller` value for this object. + /// Stores the `todayController` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. - late final TodayScreenController controller; + late final TodayScreenController todayController; + + /// Stores the `backlogController` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + late final BacklogBoardController backlogController; /// Stores the `selectedDateController` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. @@ -18,6 +22,14 @@ class _FocusFlowHomeState extends State { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. late final SchedulerCommandController commandController; + /// Stores the `ownerSettingsController` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + late final UiReadController ownerSettingsController; + + /// Private state stored as `_activeSection` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + FocusFlowSection _activeSection = FocusFlowSection.today; + /// Initializes state owned by this object before the first build. /// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance. @override @@ -25,15 +37,22 @@ class _FocusFlowHomeState extends State { super.initState(); logger.debug(() => 'FocusFlow home initializing controllers.'); selectedDateController = widget.composition.createSelectedDateController(); - controller = widget.composition.createTodayScreenController( + todayController = widget.composition.createTodayScreenController( selectedDates: selectedDateController, ); + backlogController = widget.composition.createBacklogBoardController( + selectedDates: selectedDateController, + ); + ownerSettingsController = widget.composition + .createOwnerSettingsController(); commandController = widget.composition.createCommandController( - refreshReads: controller.load, + refreshReads: _refreshReads, selectedDate: () => selectedDateController.date, ); logger.finer(() => 'FocusFlow home triggering initial Today load.'); - controller.load(); + todayController.load(); + logger.finer(() => 'FocusFlow home triggering initial settings load.'); + unawaited(ownerSettingsController.load()); } /// Releases resources owned by this object before it leaves the tree. @@ -42,7 +61,9 @@ class _FocusFlowHomeState extends State { void dispose() { logger.debug(() => 'FocusFlow home disposing controllers.'); commandController.dispose(); - controller.dispose(); + backlogController.dispose(); + todayController.dispose(); + ownerSettingsController.dispose(); selectedDateController.dispose(); unawaited(widget.composition.dispose()); super.dispose(); @@ -114,7 +135,78 @@ class _FocusFlowHomeState extends State { taskId: card.id, expectedUpdatedAt: card.expectedUpdatedAt, ); - controller.clearSelection(); + todayController.clearSelection(); + } + + /// Refreshes every read surface affected by scheduler commands. + Future _refreshReads() async { + await Future.wait([ + todayController.load(), + backlogController.load(), + ownerSettingsController.load(), + ]); + } + + /// Selects the visible app [section]. + void _selectSection(FocusFlowSection section) { + if (section == FocusFlowSection.settings) { + unawaited(_openSettings()); + return; + } + if (section == _activeSection) { + return; + } + logger.debug(() => 'FocusFlow section selected. section=${section.name}'); + setState(() { + _activeSection = section; + }); + if (section == FocusFlowSection.backlog) { + unawaited(backlogController.load()); + } + } + + /// Breaks [card] up into the drafted [children] tasks. + Future _breakUpCard( + TimelineCardModel card, + List children, + ) async { + logger.debug( + () => + 'Timeline break-up requested. cardId=${card.id} ' + 'childCount=${children.length}', + ); + await commandController.breakUpTask( + parentTaskId: card.id, + children: children, + expectedUpdatedAt: card.expectedUpdatedAt, + ); + } + + /// Opens the settings dialog seeded with the current owner settings and + /// applies the confirmed patch, if any. + Future _openSettings() async { + logger.finer(() => 'Settings action requested.'); + final state = ownerSettingsController.state; + final current = switch (state) { + SchedulerReadData(:final value) => value, + SchedulerReadEmpty(:final value) => value, + _ => null, + }; + if (current == null) { + logger.warn( + () => 'Settings action ignored: owner settings not yet loaded.', + ); + return; + } + final update = await showDialog( + context: context, + builder: (_) => SettingsDialog(initial: current), + ); + if (update == null) { + logger.finer(() => 'Settings dialog dismissed without changes.'); + return; + } + await commandController.updateOwnerSettings(update); } /// Moves the visible planning date backward by one day. @@ -156,58 +248,83 @@ class _FocusFlowHomeState extends State { return Scaffold( backgroundColor: FocusFlowTokens.appBackground, body: AppShell( - sidebar: const Sidebar(), - child: AnimatedBuilder( - animation: controller, - builder: (context, _) { - return switch (controller.state) { - TodayScreenLoading() => const Center( - child: CircularProgressIndicator(), - ), - TodayScreenFailure(:final message) => _PlanIssue( - message: message, - ), - TodayScreenEmpty(:final data) => _TodayScreenScaffold( - data: data, - selectedCard: controller.selectedCard, - onSelectCard: controller.selectCard, - onCompleteCard: _toggleCardCompletion, - onPushToNext: _pushCardToNext, - onPushToTomorrow: _pushCardToTomorrow, - onPushToBacklog: _pushCardToBacklog, - onRemoveCard: _removeCard, - onAddTask: _addGenericFlexibleTask, - onClearSelection: controller.clearSelection, - onPreviousDate: _selectPreviousDate, - onNextDate: _selectNextDate, - onChooseDate: () { - unawaited(_chooseDate()); - }, - ), - TodayScreenReady(:final data) => _TodayScreenScaffold( - data: data, - selectedCard: controller.selectedCard, - onSelectCard: controller.selectCard, - onCompleteCard: _toggleCardCompletion, - onPushToNext: _pushCardToNext, - onPushToTomorrow: _pushCardToTomorrow, - onPushToBacklog: _pushCardToBacklog, - onRemoveCard: _removeCard, - onAddTask: _addGenericFlexibleTask, - onClearSelection: controller.clearSelection, - onPreviousDate: _selectPreviousDate, - onNextDate: _selectNextDate, - onChooseDate: () { - unawaited(_chooseDate()); - }, - ), - }; - }, + sidebar: Sidebar( + activeSection: _activeSection, + onSectionSelected: _selectSection, ), + child: switch (_activeSection) { + FocusFlowSection.today => _buildTodayScreen(), + FocusFlowSection.backlog => BacklogBoardScreen( + controller: backlogController, + commandController: commandController, + ), + FocusFlowSection.projects || + FocusFlowSection.reports || + FocusFlowSection.settings => _SectionPlaceholder( + section: _activeSection, + ), + }, ), ); } + /// Builds the current Today screen branch. + Widget _buildTodayScreen() { + return AnimatedBuilder( + animation: todayController, + builder: (context, _) { + return switch (todayController.state) { + TodayScreenLoading() => const Center( + child: CircularProgressIndicator(), + ), + TodayScreenFailure(:final message) => _PlanIssue(message: message), + TodayScreenEmpty(:final data) => _TodayScreenScaffold( + data: data, + selectedCard: todayController.selectedCard, + onSelectCard: todayController.selectCard, + onCompleteCard: _toggleCardCompletion, + onPushToNext: _pushCardToNext, + onPushToTomorrow: _pushCardToTomorrow, + onPushToBacklog: _pushCardToBacklog, + onRemoveCard: _removeCard, + onAddTask: _addGenericFlexibleTask, + onBreakUpCard: _breakUpCard, + onClearSelection: todayController.clearSelection, + onPreviousDate: _selectPreviousDate, + onNextDate: _selectNextDate, + onChooseDate: () { + unawaited(_chooseDate()); + }, + onOpenSettings: () { + unawaited(_openSettings()); + }, + ), + TodayScreenReady(:final data) => _TodayScreenScaffold( + data: data, + selectedCard: todayController.selectedCard, + onSelectCard: todayController.selectCard, + onCompleteCard: _toggleCardCompletion, + onPushToNext: _pushCardToNext, + onPushToTomorrow: _pushCardToTomorrow, + onPushToBacklog: _pushCardToBacklog, + onRemoveCard: _removeCard, + onBreakUpCard: _breakUpCard, + onAddTask: _addGenericFlexibleTask, + onClearSelection: todayController.clearSelection, + onPreviousDate: _selectPreviousDate, + onNextDate: _selectNextDate, + onChooseDate: () { + unawaited(_chooseDate()); + }, + onOpenSettings: () { + unawaited(_openSettings()); + }, + ), + }; + }, + ); + } + /// Builds the dark theme wrapper used by the date picker dialog. Widget _buildDatePickerTheme(BuildContext context, Widget? child) { final theme = Theme.of(context); @@ -237,3 +354,48 @@ class _FocusFlowHomeState extends State { return CivilDate(dateTime.year, dateTime.month, dateTime.day); } } + +/// Placeholder for navigation sections that are not part of the V1 screen work. +class _SectionPlaceholder extends StatelessWidget { + /// Creates a placeholder for [section]. + const _SectionPlaceholder({required this.section}); + + /// Section represented by this placeholder. + final FocusFlowSection section; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: Text( + _labelFor(section), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ); + } + + /// Resolves display text for [section]. + String _labelFor(FocusFlowSection section) { + return switch (section) { + FocusFlowSection.today => 'Today', + FocusFlowSection.backlog => 'Backlog', + FocusFlowSection.projects => 'Projects', + FocusFlowSection.reports => 'Reports', + FocusFlowSection.settings => 'Settings', + }; + } +} diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart index a435cfd..264bb2d 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart @@ -17,11 +17,13 @@ class _TodayScreenScaffold extends StatefulWidget { required this.onPushToTomorrow, required this.onPushToBacklog, required this.onRemoveCard, + required this.onBreakUpCard, required this.onAddTask, required this.onClearSelection, required this.onPreviousDate, required this.onNextDate, required this.onChooseDate, + required this.onOpenSettings, }); /// Stores the `data` value for this object. @@ -56,6 +58,14 @@ class _TodayScreenScaffold extends StatefulWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final Future Function(TimelineCardModel card) onRemoveCard; + /// Stores the `onBreakUpCard` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Future Function( + TimelineCardModel card, + List children, + ) + onBreakUpCard; + /// Stores the `onAddTask` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final Future Function() onAddTask; @@ -76,6 +86,10 @@ class _TodayScreenScaffold extends StatefulWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final VoidCallback onChooseDate; + /// Stores the `onOpenSettings` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final VoidCallback onOpenSettings; + /// Creates the mutable state object used by this stateful widget. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. @override diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart index 86d5fb3..ecaf8fd 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart @@ -79,6 +79,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { onNextDate: widget.onNextDate, onDatePressed: widget.onChooseDate, onDateLabelPressed: _scrollTimelineToCurrentTime, + onOpenSettings: widget.onOpenSettings, ), if (widget.data.requiredBanner == null) const SizedBox(height: 16) @@ -103,6 +104,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { onPushToNext: widget.onPushToNext, onPushToTomorrow: widget.onPushToTomorrow, onPushToBacklog: widget.onPushToBacklog, + onBreakUpCard: widget.onBreakUpCard, now: () => DateTime.now().toUtc().add(widget.data.timeZoneOffset), ), @@ -134,6 +136,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { onPushToTomorrow: widget.onPushToTomorrow, onPushToBacklog: widget.onPushToBacklog, onRemove: widget.onRemoveCard, + onBreakUp: widget.onBreakUpCard, ), ), ], diff --git a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart index 1133e1e..2278729 100644 --- a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart @@ -12,7 +12,9 @@ import 'package:scheduler_core/scheduler_core.dart' show logger; import 'package:scheduler_persistence_sqlite/sqlite.dart'; +import '../controllers/backlog_board_controller.dart'; import '../controllers/scheduler_command_controller.dart'; +import '../controllers/scheduler_read_controller.dart'; import '../controllers/selected_date_controller.dart'; import '../controllers/today_screen_controller.dart'; import 'runtime/focus_flow_file_logger.dart'; @@ -204,6 +206,58 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { ); } + /// Creates the controller used by the Backlog board screen. + @override + BacklogBoardController createBacklogBoardController({ + required SelectedDateController selectedDates, + }) { + scheduler_core.logger.finer( + () => 'Creating backlog board controller. date=${date.toIsoString()}', + ); + return BacklogBoardController( + selectedDates: selectedDates, + dateLabelFor: dateLabelFor, + readBoard: (request) { + return managementUseCases.getBacklogBoard( + GetBacklogBoardRequest( + context: context('ui-read-backlog-board'), + localDate: request.localDate, + searchText: request.searchText, + filters: request.filters, + boardFilters: request.boardFilters, + sortKey: request.sortKey, + groupMode: request.groupMode, + ), + ); + }, + readTaskDetail: (taskId, localDate) { + return managementUseCases.getBacklogTaskDetail( + GetBacklogTaskDetailRequest( + context: context('ui-read-backlog-detail'), + localDate: localDate, + taskId: taskId, + ), + ); + }, + ); + } + + /// Creates a read controller for the settings screen. + @override + UiReadController createOwnerSettingsController() { + scheduler_core.logger.finer( + () => 'Creating owner settings read controller.', + ); + return ApplicationReadController( + read: () { + return managementUseCases.getOwnerSettings( + GetOwnerSettingsRequest(context: context('ui-read-owner-settings')), + ); + }, + isEmpty: (_) => false, + ); + } + /// Creates the command controller used by interactive scheduler controls. @override SchedulerCommandController createCommandController({ @@ -216,6 +270,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { ); return SchedulerCommandController( commands: commandUseCases, + management: managementUseCases, contextFor: context, selectedDate: selectedDate, refreshReads: refreshReads, diff --git a/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart b/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart index 2a30e39..45223d6 100644 --- a/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart +++ b/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart @@ -6,7 +6,9 @@ library; import 'package:scheduler_core/scheduler_core.dart'; +import '../controllers/backlog_board_controller.dart'; import '../controllers/scheduler_command_controller.dart'; +import '../controllers/scheduler_read_controller.dart'; import '../controllers/selected_date_controller.dart'; import '../controllers/today_screen_controller.dart'; @@ -32,6 +34,14 @@ abstract interface class SchedulerAppComposition { required SelectedDateController selectedDates, }); + /// Creates the controller used by the Backlog board screen. + BacklogBoardController createBacklogBoardController({ + required SelectedDateController selectedDates, + }); + + /// Creates a read controller for the settings screen. + UiReadController createOwnerSettingsController(); + /// Creates the command controller used by interactive scheduler controls. SchedulerCommandController createCommandController({ required ReadRefresh refreshReads, diff --git a/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart b/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart new file mode 100644 index 0000000..426322c --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart @@ -0,0 +1,552 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Coordinates Backlog board controller state for the FocusFlow Flutter UI. +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../models/backlog/backlog_board_screen_data.dart'; +import 'selected_date_controller.dart'; + +/// Reads Backlog board state from the scheduler application layer. +typedef BacklogBoardReader = + Future> Function( + BacklogBoardReadRequest request, + ); + +/// Reads one Backlog task detail model from the scheduler application layer. +typedef BacklogTaskDetailReader = + Future> Function( + String taskId, + CivilDate localDate, + ); + +/// Formats a selected date for Backlog loading and empty states. +typedef BacklogDateLabelFormatter = String Function(CivilDate date); + +/// Immutable controller request for a Backlog board read. +class BacklogBoardReadRequest { + /// Creates a Backlog board read request from UI filter state. + BacklogBoardReadRequest({ + required this.localDate, + this.searchText, + List filters = const [], + this.boardFilters = const BacklogBoardFilterSelection.empty(), + this.sortKey = BacklogSortKey.custom, + this.groupMode = BacklogBoardGroupMode.none, + }) : filters = List.unmodifiable(filters); + + /// Owner-local date used for slot preview context. + final CivilDate localDate; + + /// Optional search text entered by the user. + final String? searchText; + + /// User-selected backlog filters. + final List filters; + + /// User-selected board-specific filters. + final BacklogBoardFilterSelection boardFilters; + + /// User-selected task ordering. + final BacklogSortKey sortKey; + + /// User-selected grouping mode. + final BacklogBoardGroupMode groupMode; +} + +/// Base state for the Backlog board screen. +sealed class BacklogBoardScreenState { + /// Creates a Backlog board screen state. + const BacklogBoardScreenState(); +} + +/// Indicates that the Backlog board is loading. +class BacklogBoardLoading extends BacklogBoardScreenState { + /// Creates a loading Backlog state. + const BacklogBoardLoading(this.dateLabel); + + /// Display-ready date label for the read in progress. + final String dateLabel; +} + +/// Indicates that the Backlog board has renderable data. +class BacklogBoardReady extends BacklogBoardScreenState { + /// Creates a ready Backlog state. + const BacklogBoardReady(this.data); + + /// Presentation data for the Backlog board. + final BacklogBoardScreenData data; +} + +/// Indicates that the Backlog board loaded with no tasks. +class BacklogBoardEmpty extends BacklogBoardScreenState { + /// Creates an empty Backlog state. + const BacklogBoardEmpty(this.data); + + /// Presentation data for the empty Backlog board. + final BacklogBoardScreenData data; +} + +/// Indicates that the Backlog board failed to load. +class BacklogBoardFailure extends BacklogBoardScreenState { + /// Creates a failed Backlog state. + const BacklogBoardFailure({required this.message, required this.dateLabel}); + + /// User-facing failure message or code. + final String message; + + /// Display-ready date label for the failed read. + final String dateLabel; +} + +/// Loads Backlog board data and tracks UI-local selection/filter state. +class BacklogBoardController extends ChangeNotifier { + /// Creates a Backlog board controller from read callbacks. + BacklogBoardController({ + required this.readBoard, + required this.readTaskDetail, + required this.selectedDates, + required this.dateLabelFor, + }) : _state = BacklogBoardLoading(dateLabelFor(selectedDates.date)) { + selectedDates.addListener(_handleSelectedDateChanged); + } + + /// Callback used to read board state. + final BacklogBoardReader readBoard; + + /// Callback used to read selected task detail state. + final BacklogTaskDetailReader readTaskDetail; + + /// Selected planning date used for board and suggestion reads. + final SelectedDateController selectedDates; + + /// Formats dates before a backend read has returned board data. + final BacklogDateLabelFormatter dateLabelFor; + + /// Private state stored as `_state` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + BacklogBoardScreenState _state; + + /// Private state stored as `_selectedTaskId` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + String? _selectedTaskId; + + /// Private state stored as `_selectedTaskDetail` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + BacklogTaskDetailReadModel? _selectedTaskDetail; + + /// Private state stored as `_selectedTaskDetailError` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + String? _selectedTaskDetailError; + + /// Private state stored as `_selectedTaskActionMessage` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + String? _selectedTaskActionMessage; + + /// Private state stored as `_searchText` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + String? _searchText; + + /// Private state stored as `_filters` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + List _filters = const []; + + /// Private state stored as `_boardFilters` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + BacklogBoardFilterSelection _boardFilters = + const BacklogBoardFilterSelection.empty(); + + /// Private state stored as `_sortKey` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + BacklogSortKey _sortKey = BacklogSortKey.custom; + + /// Private state stored as `_groupMode` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + BacklogBoardGroupMode _groupMode = BacklogBoardGroupMode.none; + + /// Private state stored as `_loadSequence` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + var _loadSequence = 0; + + /// Private state stored as `_detailSequence` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + var _detailSequence = 0; + + /// Private state stored as `_isDisposed` for this implementation. + /// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior. + var _isDisposed = false; + + /// Current Backlog board screen state. + BacklogBoardScreenState get state => _state; + + /// Currently selected Backlog task id, if any. + String? get selectedTaskId => _selectedTaskId; + + /// Detail model for the currently selected Backlog task, if it has loaded. + BacklogTaskDetailReadModel? get selectedTaskDetail => _selectedTaskDetail; + + /// Drawer-local error message for the selected task detail read. + String? get selectedTaskDetailError => _selectedTaskDetailError; + + /// Drawer-local action message for the selected task. + String? get selectedTaskActionMessage => _selectedTaskActionMessage; + + /// Whether a selected task detail read is currently unresolved. + bool get isSelectedTaskDetailLoading => + _selectedTaskId != null && + _selectedTaskDetail == null && + _selectedTaskDetailError == null; + + /// Current search text applied to board reads. + String? get searchText => _searchText; + + /// Current filters applied to board reads. + List get filters => _filters; + + /// Current board-specific filters applied to board reads. + BacklogBoardFilterSelection get boardFilters => _boardFilters; + + /// Current sort key applied to board reads. + BacklogSortKey get sortKey => _sortKey; + + /// Current group mode applied to board reads. + BacklogBoardGroupMode get groupMode => _groupMode; + + /// Loads the current Backlog board state. + Future load() async { + final date = selectedDates.date; + final sequence = _nextLoadSequence(); + logger.debug( + () => + 'Backlog board load started. date=${date.toIsoString()} sequence=$sequence', + ); + _state = BacklogBoardLoading(dateLabelFor(date)); + notifyListeners(); + try { + final result = await readBoard( + BacklogBoardReadRequest( + localDate: date, + searchText: _searchText, + filters: _filters, + boardFilters: _boardFilters, + sortKey: _sortKey, + groupMode: _groupMode, + ), + ); + if (_isStale(sequence)) { + logger.finer( + () => + 'Ignoring stale Backlog board load result. ' + 'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence', + ); + return; + } + final failure = result.failure; + if (failure != null) { + logger.warn( + () => + 'Backlog board load returned failure. ' + 'date=${date.toIsoString()} code=${failure.code.name} ' + 'detailCode=${failure.detailCode} entityId=${failure.entityId}', + ); + _state = BacklogBoardFailure( + message: failure.detailCode ?? failure.code.name, + dateLabel: dateLabelFor(date), + ); + notifyListeners(); + return; + } + + final board = result.requireValue; + _syncSelectedTask(board); + final data = BacklogBoardScreenData( + board: board, + selectedTaskDetail: _selectedTaskDetail, + ); + _state = board.summary.totalTaskCount == 0 + ? BacklogBoardEmpty(data) + : BacklogBoardReady(data); + logger.debug( + () => + 'Backlog board load finished. date=${date.toIsoString()} ' + 'sequence=$sequence taskCount=${board.summary.totalTaskCount}', + ); + notifyListeners(); + } on Object catch (error, stackTrace) { + if (_isStale(sequence)) { + logger.finer( + () => + 'Ignoring stale Backlog board load exception. ' + 'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence', + ); + return; + } + logger.error( + () => + 'Backlog board load threw unexpectedly. date=${date.toIsoString()} sequence=$sequence', + error: error, + stackTrace: stackTrace, + ); + _state = BacklogBoardFailure( + message: error.toString(), + dateLabel: dateLabelFor(date), + ); + notifyListeners(); + } + } + + /// Selects [taskId] and requests drawer detail data. + Future selectTask(String taskId) async { + if (_selectedTaskId == taskId && _selectedTaskDetail != null) { + return; + } + _selectedTaskId = taskId; + _selectedTaskDetail = null; + _selectedTaskDetailError = null; + _selectedTaskActionMessage = null; + notifyListeners(); + await _loadSelectedTaskDetail(taskId); + } + + /// Reloads the currently selected task detail, if a task is selected. + Future refreshSelectedTaskDetail() async { + final taskId = _selectedTaskId; + if (taskId == null) { + return; + } + _selectedTaskDetail = null; + _selectedTaskDetailError = null; + _selectedTaskActionMessage = null; + _refreshDataState(); + notifyListeners(); + await _loadSelectedTaskDetail(taskId); + } + + /// Shows [message] in the selected task drawer without mutating scheduler data. + void showSelectedTaskActionMessage(String message) { + if (_selectedTaskId == null) { + return; + } + _selectedTaskActionMessage = message; + notifyListeners(); + } + + /// Clears UI-local task selection. + void clearSelection() { + if (_selectedTaskId == null && _selectedTaskDetail == null) { + return; + } + _selectedTaskId = null; + _selectedTaskDetail = null; + _selectedTaskDetailError = null; + _selectedTaskActionMessage = null; + _detailSequence += 1; + _refreshDataState(); + notifyListeners(); + } + + /// Updates search text and reloads the board. + Future updateSearchText(String? value) async { + final trimmed = value?.trim(); + final next = trimmed == null || trimmed.isEmpty ? null : trimmed; + if (next == _searchText) { + return; + } + _searchText = next; + await load(); + } + + /// Updates selected filters and reloads the board. + Future updateFilters(List value) async { + _filters = List.unmodifiable(value); + await load(); + } + + /// Updates legacy and board-specific filters together and reloads once. + Future updateFilterState({ + required List filters, + required BacklogBoardFilterSelection boardFilters, + }) async { + _filters = List.unmodifiable(filters); + _boardFilters = boardFilters; + await load(); + } + + /// Updates board-specific filters and reloads the board. + Future updateBoardFilters(BacklogBoardFilterSelection value) async { + _boardFilters = value; + await load(); + } + + /// Updates sort key and reloads the board. + Future updateSortKey(BacklogSortKey value) async { + if (value == _sortKey) { + return; + } + _sortKey = value; + await load(); + } + + /// Updates group mode and reloads the board. + Future updateGroupMode(BacklogBoardGroupMode value) async { + if (value == _groupMode) { + return; + } + _groupMode = value; + await load(); + } + + /// Retries the latest board read. + Future retry() => load(); + + /// Releases selected-date listeners owned by this controller. + @override + void dispose() { + _isDisposed = true; + selectedDates.removeListener(_handleSelectedDateChanged); + super.dispose(); + } + + /// Loads detail data for the already-selected [taskId]. + Future _loadSelectedTaskDetail(String taskId) async { + final sequence = _nextDetailSequence(); + final date = selectedDates.date; + logger.finer( + () => + 'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence', + ); + try { + final result = await readTaskDetail(taskId, date); + if (_isDetailStale(sequence, taskId)) { + logger.finer( + () => + 'Ignoring stale Backlog detail load result. ' + 'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence', + ); + return; + } + final failure = result.failure; + if (failure != null) { + logger.warn( + () => + 'Backlog detail load returned failure. taskId=$taskId ' + 'code=${failure.code.name} detailCode=${failure.detailCode}', + ); + _selectedTaskDetail = null; + _selectedTaskDetailError = failure.detailCode ?? failure.code.name; + _refreshDataState(); + notifyListeners(); + return; + } + _selectedTaskDetail = result.requireValue; + _selectedTaskDetailError = null; + _refreshDataState(); + notifyListeners(); + } on Object catch (error, stackTrace) { + if (_isDetailStale(sequence, taskId)) { + logger.finer( + () => + 'Ignoring stale Backlog detail load exception. ' + 'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence', + ); + return; + } + logger.error( + () => + 'Backlog detail load threw unexpectedly. taskId=$taskId sequence=$sequence', + error: error, + stackTrace: stackTrace, + ); + _selectedTaskDetail = null; + _selectedTaskDetailError = error.toString(); + _refreshDataState(); + notifyListeners(); + } + } + + /// Runs the `_handleSelectedDateChanged` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _handleSelectedDateChanged() { + logger.debug( + () => + 'Backlog board selected date changed. date=${selectedDates.date.toIsoString()}', + ); + clearSelection(); + unawaited(load()); + } + + /// Runs the `_syncSelectedTask` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _syncSelectedTask(BacklogBoardQueryResult board) { + final selectedId = _selectedTaskId; + if (selectedId == null) { + return; + } + final stillVisible = board.columns + .expand((column) => column.items) + .any((item) => item.taskId == selectedId); + if (stillVisible) { + return; + } + _selectedTaskId = null; + _selectedTaskDetail = null; + _selectedTaskDetailError = null; + _selectedTaskActionMessage = null; + } + + /// Runs the `_refreshDataState` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + void _refreshDataState() { + switch (_state) { + case BacklogBoardReady(:final data): + _state = BacklogBoardReady( + BacklogBoardScreenData( + board: data.board, + selectedTaskDetail: _selectedTaskDetail, + ), + ); + case BacklogBoardEmpty(:final data): + _state = BacklogBoardEmpty( + BacklogBoardScreenData( + board: data.board, + selectedTaskDetail: _selectedTaskDetail, + ), + ); + case BacklogBoardLoading() || BacklogBoardFailure(): + break; + } + } + + /// Runs the `_nextLoadSequence` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _nextLoadSequence() { + _loadSequence += 1; + return _loadSequence; + } + + /// Runs the `_nextDetailSequence` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + int _nextDetailSequence() { + _detailSequence += 1; + return _detailSequence; + } + + /// Runs the `_isStale` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + bool _isStale(int sequence) { + return _isDisposed || sequence != _loadSequence; + } + + /// Runs the `_isDetailStale` helper used inside this library. + /// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read. + bool _isDetailStale(int sequence, String taskId) { + return _isDisposed || + sequence != _detailSequence || + taskId != _selectedTaskId; + } +} diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart index 5cd86fd..37a0f3a 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -8,6 +8,7 @@ class SchedulerCommandController extends ChangeNotifier { /// Creates a command controller from scheduler use cases and UI callbacks. SchedulerCommandController({ required this.commands, + required this.management, required this.contextFor, required this.selectedDate, required this.refreshReads, @@ -21,6 +22,10 @@ class SchedulerCommandController extends ChangeNotifier { /// Scheduler write use cases invoked by this controller. final V1ApplicationCommandUseCases commands; + /// Non-scheduling management use cases (owner settings, projects, locked + /// time) invoked by this controller. + final V1ApplicationManagementUseCases management; + /// Factory for command-scoped application operation contexts. final OperationContextFactory contextFor; @@ -134,6 +139,116 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// Schedules a backlog task into a specific suggested slot. + Future scheduleBacklogItemAtSuggestedSlot({ + required String taskId, + required DateTime scheduledStartUtc, + required DateTime scheduledEndUtc, + required DateTime expectedUpdatedAt, + }) async { + logger.debug( + () => + 'UI command requested: schedule backlog item at suggested slot. ' + 'taskId=$taskId scheduledStart=${scheduledStartUtc.toIso8601String()} ' + 'scheduledEnd=${scheduledEndUtc.toIso8601String()} ' + 'expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}', + ); + await _run( + label: 'Scheduling task', + successMessage: 'Task scheduled', + refreshAfterSuccess: true, + action: (operationId) { + final commandAt = now(); + return commands.scheduleBacklogItemAtSuggestedSlot( + context: contextFor(operationId, now: commandAt), + localDate: selectedDate(), + taskId: taskId, + scheduledStart: scheduledStartUtc, + scheduledEnd: scheduledEndUtc, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Breaks a Backlog task into direct child tasks. + Future breakUpBacklogTask({ + required String parentTaskId, + required List children, + required DateTime expectedUpdatedAt, + }) async { + logger.debug( + () => + 'UI command requested: break up backlog task. ' + 'parentTaskId=$parentTaskId childCount=${children.length} ' + 'expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}', + ); + await _run( + label: 'Breaking up task', + successMessage: 'Task broken up', + refreshAfterSuccess: true, + action: (operationId) { + final commandAt = now(); + return commands.breakUpTask( + context: contextFor(operationId, now: commandAt), + parentTaskId: parentTaskId, + children: children, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Moves a Backlog task to the Someday/Not Now board area. + Future pushBacklogTaskToSomeday({ + required String taskId, + required DateTime expectedUpdatedAt, + }) async { + logger.debug( + () => + 'UI command requested: push backlog task to someday. ' + 'taskId=$taskId expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}', + ); + await _run( + label: 'Moving task to Someday', + successMessage: 'Moved to Someday', + refreshAfterSuccess: true, + action: (operationId) { + final commandAt = now(); + return commands.pushBacklogTaskToSomeday( + context: contextFor(operationId, now: commandAt), + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Removes a task from the active Backlog without deleting its record. + Future archiveBacklogTask({ + required String taskId, + required DateTime expectedUpdatedAt, + }) async { + logger.debug( + () => + 'UI command requested: archive backlog task. ' + 'taskId=$taskId expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}', + ); + await _run( + label: 'Removing from Backlog', + successMessage: 'Removed from active Backlog.', + refreshAfterSuccess: true, + action: (operationId) { + final commandAt = now(); + return commands.archiveBacklogTask( + context: contextFor(operationId, now: commandAt), + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + /// Completes a planned flexible task from the Today view. Future completeFlexibleTask({ required String taskId, @@ -184,7 +299,7 @@ class SchedulerCommandController extends ChangeNotifier { TaskType.locked || TaskType.surprise || TaskType.freeSlot => Future.value( - ApplicationResult.failure( + ApplicationResult.failure( ApplicationFailure( code: ApplicationFailureCode.validation, entityId: taskId, @@ -304,6 +419,59 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// Breaks a parent task into direct child tasks. + Future breakUpTask({ + required String parentTaskId, + required List children, + DateTime? expectedUpdatedAt, + }) async { + final commandAt = now(); + logger.debug( + () => + 'UI command requested: break up task. ' + 'parentTaskId=$parentTaskId childCount=${children.length} ' + 'hasExpectedUpdatedAt=${expectedUpdatedAt != null}', + ); + await _run( + label: 'Breaking up task', + successMessage: 'Task broken up', + refreshAfterSuccess: true, + action: (operationId) { + return commands.breakUpTask( + context: contextFor(operationId, now: commandAt), + parentTaskId: parentTaskId, + children: children, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Applies a patch to the owner's persisted settings. + Future updateOwnerSettings(OwnerSettingsUpdate update) async { + final commandAt = now(); + logger.debug( + () => + 'UI command requested: update owner settings. ' + 'hasTimeZone=${update.timeZoneId != null} ' + 'hasDayStart=${update.dayStart != null} ' + 'hasDayEnd=${update.dayEnd != null} ' + 'hasCompactMode=${update.compactModeEnabled != null} ' + 'hasStaleness=${update.backlogStalenessSettings != null}', + ); + await _run( + label: 'Saving settings', + successMessage: 'Settings saved', + refreshAfterSuccess: true, + action: (operationId) { + return management.updateOwnerSettings( + context: contextFor(operationId, now: commandAt), + update: update, + ); + }, + ); + } + /// Permanently removes a task from persistence. Future removeTask({ required String taskId, @@ -332,14 +500,11 @@ class SchedulerCommandController extends ChangeNotifier { /// Runs the `_run` operation and completes after its asynchronous work finishes. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. - Future _run({ + Future _run({ required String label, required String successMessage, required bool refreshAfterSuccess, - required Future> Function( - String operationId, - ) - action, + required Future> Function(String operationId) action, }) async { final operationId = _nextOperationId(); logger.debug( diff --git a/apps/focus_flow_flutter/lib/models/backlog/backlog_board_screen_data.dart b/apps/focus_flow_flutter/lib/models/backlog/backlog_board_screen_data.dart new file mode 100644 index 0000000..2d0c9ef --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/backlog/backlog_board_screen_data.dart @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Defines Backlog board screen presentation data. +library; + +import 'package:scheduler_core/scheduler_core.dart'; + +/// Presentation data consumed by the Backlog board screen shell. +class BacklogBoardScreenData { + /// Creates Backlog screen data from a board query result and optional detail. + const BacklogBoardScreenData({required this.board, this.selectedTaskDetail}); + + /// Public scheduler read model for the current Backlog board. + final BacklogBoardQueryResult board; + + /// Public scheduler read model for the selected task drawer, if loaded. + final BacklogTaskDetailReadModel? selectedTaskDetail; + + /// Stable board columns in display order. + List get columns => board.columns; + + /// Summary read model for the current board filters. + BacklogBoardSummaryReadModel get summary => board.summary; + + /// Selected task id if a drawer should be considered open. + String? get selectedTaskId => selectedTaskDetail?.item.taskId; +} diff --git a/apps/focus_flow_flutter/lib/models/navigation/focus_flow_section.dart b/apps/focus_flow_flutter/lib/models/navigation/focus_flow_section.dart new file mode 100644 index 0000000..ac7d295 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/navigation/focus_flow_section.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Defines primary FocusFlow app sections. +library; + +/// Top-level sections selectable from the FocusFlow sidebar. +enum FocusFlowSection { + /// Today timeline and compact planning surface. + today, + + /// Backlog board and backlog task detail surface. + backlog, + + /// Project setup and project defaults surface. + projects, + + /// Reports and review surface. + reports, + + /// Settings surface. + settings, +} diff --git a/apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart b/apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart new file mode 100644 index 0000000..51440d9 --- /dev/null +++ b/apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Defines Backlog board visual tokens for the FocusFlow Flutter UI. +library; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'focus_flow_tokens.dart'; + +/// Maps Backlog board read-model tokens to Flutter colors and icons. +abstract final class BacklogBoardVisualTokens { + /// Accent color for the Do Next bucket. + static const doNextAccent = Color(0xFF24E65A); + + /// Accent color for the Need Time Block bucket. + static const needTimeBlockAccent = Color(0xFFFFA51F); + + /// Accent color for the Break Up First bucket. + static const breakUpFirstAccent = Color(0xFFB45CFF); + + /// Accent color for the Not Now bucket. + static const notNowAccent = Color(0xFF35BFFF); + + /// Red flag color for high-priority Backlog cards. + static const priorityHigh = Color(0xFFFF3F5B); + + /// Yellow flag color for medium-priority Backlog cards. + static const priorityMedium = Color(0xFFFFD447); + + /// Green flag color for low-priority Backlog cards. + static const priorityLow = Color(0xFF4BE064); + + /// Project dot color for Home. + static const projectHome = Color(0xFF4D86FF); + + /// Project dot color for Work. + static const projectWork = Color(0xFFFF7A1A); + + /// Project dot color for Personal. + static const projectPersonal = Color(0xFFB45CFF); + + /// Project dot color for Learning. + static const projectLearning = Color(0xFF38BDF8); + + /// Project dot color for Side Project. + static const projectSideProject = Color(0xFF2F6BFF); + + /// Resolves a Backlog column accent token to a display color. + static Color accentTokenColor( + String token, { + BacklogBoardBucket? fallbackBucket, + }) { + return switch (token) { + 'backlog-do-next' => doNextAccent, + 'backlog-time-block' => needTimeBlockAccent, + 'backlog-break-up' => breakUpFirstAccent, + 'backlog-not-now' => notNowAccent, + 'green' => doNextAccent, + 'yellow' => needTimeBlockAccent, + 'purple' => breakUpFirstAccent, + 'blue' => notNowAccent, + _ => + fallbackBucket == null + ? FocusFlowTokens.textMuted + : bucketAccent(fallbackBucket), + }; + } + + /// Resolves a board bucket to its accent color. + static Color bucketAccent(BacklogBoardBucket bucket) { + return switch (bucket) { + BacklogBoardBucket.doNext => doNextAccent, + BacklogBoardBucket.needTimeBlock => needTimeBlockAccent, + BacklogBoardBucket.breakUpFirst => breakUpFirstAccent, + BacklogBoardBucket.notNow => notNowAccent, + }; + } + + /// Resolves a board bucket to its header icon. + static IconData bucketIcon(BacklogBoardBucket bucket) { + return switch (bucket) { + BacklogBoardBucket.doNext => Icons.check_circle_outline, + BacklogBoardBucket.needTimeBlock => Icons.schedule, + BacklogBoardBucket.breakUpFirst => Icons.adjust, + BacklogBoardBucket.notNow => Icons.cloud_outlined, + }; + } + + /// Resolves a task priority to a flag color. + static Color priorityColor(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryHigh || PriorityLevel.high => priorityHigh, + PriorityLevel.medium => priorityMedium, + PriorityLevel.low || PriorityLevel.veryLow => priorityLow, + null => FocusFlowTokens.textFaint, + }; + } + + /// Resolves a priority distribution color token to a flag color. + static Color priorityColorToken(String? token) { + return switch (token) { + 'priority-veryHigh' || 'priority-high' => priorityHigh, + 'priority-medium' => priorityMedium, + 'priority-low' || 'priority-veryLow' => priorityLow, + _ => FocusFlowTokens.textFaint, + }; + } + + /// Resolves a task priority to a compact card label. + static String priorityLabel(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryHigh || PriorityLevel.high => 'High', + PriorityLevel.medium => 'Med', + PriorityLevel.low || PriorityLevel.veryLow => 'Low', + null => 'None', + }; + } + + /// Resolves a project color token to the Backlog board dot color. + static Color projectColor(String token) { + return switch (token) { + 'project-home' || 'home' => projectHome, + 'project-work' || 'work' => projectWork, + 'project-personal' || 'personal' => projectPersonal, + 'project-learning' || 'learning' => projectLearning, + 'project-side-project' || 'side-project' => projectSideProject, + _ => FocusFlowTokens.textMuted, + }; + } + + /// Resolves a duration distribution id to a chart segment color. + static Color durationColor(String id) { + return switch (id) { + '0-30' => priorityLow, + '30-60' => notNowAccent, + '60-120' => breakUpFirstAccent, + '120+' => FocusFlowTokens.accentMagenta, + _ => FocusFlowTokens.textFaint, + }; + } + + /// Resolves a reward level to the number of solid reward marks to render. + static int rewardCount(RewardLevel reward) { + return switch (reward) { + RewardLevel.notSet => 0, + RewardLevel.veryLow => 1, + RewardLevel.low => 2, + RewardLevel.medium => 3, + RewardLevel.high => 4, + RewardLevel.veryHigh => 5, + }; + } + + /// Resolves a Backlog tag chip background color for drawer surfaces. + static Color tagChipBackground(String label) { + return switch (label.toLowerCase()) { + 'someday' => notNowAccent.withValues(alpha: 0.16), + _ => FocusFlowTokens.elevatedPanel, + }; + } + + /// Resolves a Backlog tag chip foreground color for drawer surfaces. + static Color tagChipForeground(String label) { + return switch (label.toLowerCase()) { + 'someday' => notNowAccent, + _ => FocusFlowTokens.textMuted, + }; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_column.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_column.dart new file mode 100644 index 0000000..cf554e1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_column.dart @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders Backlog board columns for the FocusFlow Flutter UI. +library; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../theme/backlog_board_visual_tokens.dart'; +import '../../theme/focus_flow_tokens.dart'; +import 'backlog_task_card.dart'; + +/// Callback invoked when a Backlog task should become the selected task. +typedef BacklogTaskSelected = void Function(String taskId); + +/// Callback invoked when the user requests a new task for a bucket. +typedef BacklogBucketAddRequested = void Function(BacklogBoardBucket bucket); + +/// Board column containing a Backlog bucket header, task cards, and footer. +class BacklogBoardColumn extends StatelessWidget { + /// Creates a Backlog board column. + const BacklogBoardColumn({ + required this.column, + required this.selectedTaskId, + required this.onTaskSelected, + required this.onAddTask, + super.key, + }); + + /// Column read model supplied by scheduler core. + final BacklogBoardColumnReadModel column; + + /// Currently selected task id, if any. + final String? selectedTaskId; + + /// Selection callback for task cards. + final BacklogTaskSelected onTaskSelected; + + /// Add-task callback shared by header and footer controls. + final BacklogBucketAddRequested onAddTask; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final accent = BacklogBoardVisualTokens.accentTokenColor( + column.accentToken, + fallbackBucket: column.bucket, + ); + return DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: accent.withValues(alpha: 0.64)), + boxShadow: [ + BoxShadow( + color: accent.withValues(alpha: 0.06), + blurRadius: 28, + spreadRadius: -10, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + BacklogColumnHeader( + column: column, + accent: accent, + onAddTask: () { + onAddTask(column.bucket); + }, + ), + const SizedBox(height: 14), + Expanded( + child: column.items.isEmpty + ? _EmptyColumnHint(accent: accent) + : ListView.separated( + padding: EdgeInsets.zero, + itemCount: column.items.length, + separatorBuilder: (context, index) => + const SizedBox(height: 8), + itemBuilder: (context, index) { + final item = column.items[index]; + return BacklogTaskCard( + item: item, + selected: item.taskId == selectedTaskId, + onTap: () { + onTaskSelected(item.taskId); + }, + ); + }, + ), + ), + const SizedBox(height: 9), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () { + onAddTask(column.bucket); + }, + icon: Icon(Icons.add, color: accent, size: 18), + label: Text('Add task', style: TextStyle(color: accent)), + ), + ), + ], + ), + ), + ); + } +} + +/// Header for a Backlog board column. +class BacklogColumnHeader extends StatelessWidget { + /// Creates a Backlog column header. + const BacklogColumnHeader({ + required this.column, + required this.accent, + required this.onAddTask, + super.key, + }); + + /// Column read model that supplies title, subtitle, bucket, and count. + final BacklogBoardColumnReadModel column; + + /// Accent color resolved from the column token. + final Color accent; + + /// Callback invoked by the header add button. + final VoidCallback onAddTask; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + BacklogBoardVisualTokens.bucketIcon(column.bucket), + color: accent, + size: 22, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + column.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 14.5, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 8), + _ColumnCountPill(label: '${column.count}', color: accent), + const SizedBox(width: 4), + IconButton( + onPressed: onAddTask, + icon: const Icon(Icons.add), + color: FocusFlowTokens.textMuted, + tooltip: 'Add task', + iconSize: 18, + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 7), + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + column.subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 11, + height: 1.25, + ), + ), + ), + ], + ); + } +} + +/// Small count pill used by Backlog column headers. +class _ColumnCountPill extends StatelessWidget { + /// Creates a column count pill. + const _ColumnCountPill({required this.label, required this.color}); + + /// Count label. + final String label; + + /// Accent color for the pill. + final Color color; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(7), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +/// Placeholder content for an empty board column. +class _EmptyColumnHint extends StatelessWidget { + /// Creates an empty-column hint. + const _EmptyColumnHint({required this.accent}); + + /// Accent color for the empty state icon. + final Color accent; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.elevatedPanel.withValues(alpha: 0.42), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.62), + ), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox_outlined, color: accent, size: 22), + const SizedBox(height: 8), + const Text( + 'No tasks here', + textAlign: TextAlign.center, + style: TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart new file mode 100644 index 0000000..e74be71 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart @@ -0,0 +1,961 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders Backlog board controls for the FocusFlow Flutter UI. +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../controllers/backlog_board_controller.dart'; +import '../../theme/backlog_board_visual_tokens.dart'; +import '../../theme/focus_flow_tokens.dart'; + +/// Header controls for Compact/Board mode and settings. +class BacklogModeAndSettingsControls extends StatelessWidget { + /// Creates mode and settings controls. + const BacklogModeAndSettingsControls({super.key}); + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 42, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: Row( + children: const [ + _ModeSegment(label: 'Compact', active: false), + _ModeSegment(label: 'Board', active: true), + ], + ), + ), + const SizedBox(width: 18), + OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.wb_sunny_outlined, size: 18), + label: const Text('Settings'), + style: OutlinedButton.styleFrom( + fixedSize: const Size(122, 42), + foregroundColor: FocusFlowTokens.textPrimary, + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ); + } +} + +/// Search, filter, sort, group, and new-task controls. +class BacklogBoardTopControls extends StatefulWidget { + /// Creates Backlog board top controls. + const BacklogBoardTopControls({ + required this.controller, + required this.onNewTask, + super.key, + }); + + /// Controller updated by shell controls. + final BacklogBoardController controller; + + /// Callback invoked by the New Task button. + final VoidCallback? onNewTask; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State createState() => + _BacklogBoardTopControlsState(); +} + +/// State for Backlog board top controls. +class _BacklogBoardTopControlsState extends State { + /// Text controller for the search field. + late final TextEditingController _searchController; + + /// Initializes text controller state from the current board controller. + @override + void initState() { + super.initState(); + _searchController = TextEditingController( + text: widget.controller.searchText ?? '', + ); + } + + /// Releases text controller resources. + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final activeFilterCount = + widget.controller.filters.length + + widget.controller.boardFilters.activeFilterCount; + final searchText = widget.controller.searchText ?? ''; + if (searchText.isEmpty && _searchController.text.isNotEmpty) { + _searchController.clear(); + } + return Row( + children: [ + Expanded( + child: SizedBox( + height: 44, + child: TextField( + key: const ValueKey('backlog-search-field'), + controller: _searchController, + onChanged: (value) { + unawaited(widget.controller.updateSearchText(value)); + }, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + decoration: InputDecoration( + prefixIcon: const Icon( + Icons.search, + color: FocusFlowTokens.textMuted, + ), + suffixIcon: searchText.isEmpty + ? null + : IconButton( + key: const ValueKey('backlog-search-clear'), + tooltip: 'Clear search', + onPressed: () { + _searchController.clear(); + unawaited(widget.controller.updateSearchText(null)); + }, + icon: const Icon( + Icons.close, + color: FocusFlowTokens.textMuted, + ), + ), + hintText: 'Search backlog...', + hintStyle: const TextStyle(color: FocusFlowTokens.textFaint), + filled: true, + fillColor: FocusFlowTokens.panelBackground, + contentPadding: EdgeInsets.zero, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: FocusFlowTokens.subtleBorder, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide( + color: FocusFlowTokens.accentMagenta, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 16), + _BacklogControlButton( + key: const ValueKey('backlog-filter-button'), + icon: Icons.filter_alt_outlined, + label: activeFilterCount == 0 + ? 'Filters' + : 'Filters $activeFilterCount', + onPressed: () { + unawaited(_showFilterDialog(context, widget.controller)); + }, + ), + const SizedBox(width: 14), + _BacklogMenuControlButton( + key: const ValueKey('backlog-sort-menu-button'), + icon: Icons.tune, + label: 'Sort: ${_sortLabel(widget.controller.sortKey)}', + width: 158, + selectedValue: widget.controller.sortKey, + options: _sortOptions, + onSelected: (value) { + unawaited(widget.controller.updateSortKey(value)); + }, + ), + const SizedBox(width: 14), + _BacklogMenuControlButton( + key: const ValueKey('backlog-group-menu-button'), + icon: Icons.dashboard_customize_outlined, + label: 'Group: ${_groupLabel(widget.controller.groupMode)}', + width: 156, + selectedValue: widget.controller.groupMode, + options: _groupOptions, + onSelected: (value) { + unawaited(widget.controller.updateGroupMode(value)); + }, + ), + const SizedBox(width: 18), + FilledButton( + key: const ValueKey('backlog-new-task-button'), + onPressed: widget.onNewTask, + style: FilledButton.styleFrom( + fixedSize: const Size(146, 44), + padding: EdgeInsets.zero, + backgroundColor: FocusFlowTokens.accentMagenta, + foregroundColor: FocusFlowTokens.textPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add, size: 18), + SizedBox(width: 8), + Text('New Task'), + SizedBox(width: 4), + Icon(Icons.keyboard_arrow_down, size: 18), + ], + ), + ), + ), + ], + ); + } +} + +/// One segment in the Backlog mode toggle. +class _ModeSegment extends StatelessWidget { + /// Creates a mode segment. + const _ModeSegment({required this.label, required this.active}); + + /// Segment label. + final String label; + + /// Whether this segment is selected. + final bool active; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Container( + width: 78, + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? FocusFlowTokens.glassPanelStrong : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: active + ? Border.all(color: FocusFlowTokens.navBorder) + : Border.all(color: Colors.transparent), + ), + child: Text( + label, + style: TextStyle( + color: active + ? FocusFlowTokens.accentMagenta + : FocusFlowTokens.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +/// Compact outlined control button used by the Backlog shell. +class _BacklogControlButton extends StatelessWidget { + /// Creates a Backlog control button. + const _BacklogControlButton({ + required this.icon, + required this.label, + required this.onPressed, + super.key, + }); + + /// Leading icon for the control. + final IconData icon; + + /// Control label. + final String label; + + /// Callback invoked when the control is pressed. + final VoidCallback onPressed; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 18), + label: Text(label), + style: OutlinedButton.styleFrom( + fixedSize: const Size(132, 44), + foregroundColor: FocusFlowTokens.textPrimary, + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ); + } +} + +/// Generic popup menu button styled like the Backlog control row. +class _BacklogMenuControlButton extends StatelessWidget { + /// Creates a popup menu control. + const _BacklogMenuControlButton({ + required this.icon, + required this.label, + required this.width, + required this.selectedValue, + required this.options, + required this.onSelected, + super.key, + }); + + /// Leading icon for the control. + final IconData icon; + + /// Button label. + final String label; + + /// Fixed button width. + final double width; + + /// Currently selected value. + final T selectedValue; + + /// Menu options. + final List<_MenuOption> options; + + /// Selection callback. + final ValueChanged onSelected; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return PopupMenuButton( + color: FocusFlowTokens.elevatedPanel, + onSelected: onSelected, + itemBuilder: (context) { + return [ + for (final option in options) + PopupMenuItem( + value: option.value, + child: Row( + children: [ + Icon( + option.value == selectedValue + ? Icons.check + : Icons.circle_outlined, + size: 16, + color: option.value == selectedValue + ? FocusFlowTokens.accentMagenta + : FocusFlowTokens.textFaint, + ), + const SizedBox(width: 10), + Text( + option.label, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + ), + ], + ), + ), + ]; + }, + child: Container( + width: width, + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: Row( + children: [ + Icon(icon, size: 18, color: FocusFlowTokens.textPrimary), + const SizedBox(width: 9), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + const Icon( + Icons.keyboard_arrow_down, + color: FocusFlowTokens.textMuted, + size: 18, + ), + ], + ), + ), + ); + } +} + +/// Menu option for a Backlog popup control. +class _MenuOption { + /// Creates a menu option. + const _MenuOption({required this.value, required this.label}); + + /// Option value. + final T value; + + /// Display label. + final String label; +} + +/// Result returned from the filter dialog. +class _FilterDialogResult { + /// Creates a filter dialog result. + _FilterDialogResult({ + required List legacyFilters, + required this.boardFilters, + }) : legacyFilters = List.unmodifiable(legacyFilters); + + /// Legacy backlog filters to apply. + final List legacyFilters; + + /// Board-specific filters to apply. + final BacklogBoardFilterSelection boardFilters; +} + +/// Dialog for editing Backlog board filters. +class _BacklogFilterDialog extends StatefulWidget { + /// Creates a Backlog filter dialog. + const _BacklogFilterDialog({ + required this.initialLegacyFilters, + required this.initialBoardFilters, + required this.projectOptions, + }); + + /// Legacy filters active before the dialog opened. + final List initialLegacyFilters; + + /// Board filters active before the dialog opened. + final BacklogBoardFilterSelection initialBoardFilters; + + /// Project options available to the board. + final List projectOptions; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State<_BacklogFilterDialog> createState() => _BacklogFilterDialogState(); +} + +/// State for the Backlog filter dialog. +class _BacklogFilterDialogState extends State<_BacklogFilterDialog> { + /// Selected priority groups. + late Set _priorityGroups; + + /// Selected project ids. + late Set _projectIds; + + /// Selected duration buckets. + late Set _durationBuckets; + + /// Selected board buckets. + late Set _buckets; + + /// Whether missing-duration tasks are selected. + late bool _missingDurationOnly; + + /// Whether no-reward tasks are selected. + late bool _noRewardSetOnly; + + /// Initializes mutable filter selections. + @override + void initState() { + super.initState(); + _priorityGroups = {...widget.initialBoardFilters.priorityGroups}; + _projectIds = {...widget.initialBoardFilters.projectIds}; + _durationBuckets = {...widget.initialBoardFilters.durationBuckets}; + _buckets = {...widget.initialBoardFilters.buckets}; + _missingDurationOnly = widget.initialBoardFilters.missingDurationOnly; + _noRewardSetOnly = widget.initialLegacyFilters.contains( + BacklogFilter.noRewardSet, + ); + } + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: FocusFlowTokens.elevatedPanel, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560, maxHeight: 680), + child: Padding( + padding: const EdgeInsets.fromLTRB(22, 20, 22, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Row( + children: [ + Icon( + Icons.filter_alt_outlined, + color: FocusFlowTokens.accentMagenta, + ), + SizedBox(width: 10), + Expanded( + child: Text( + 'Filters', + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _FilterSection( + title: 'Priority', + children: [ + _checkbox( + label: 'High priority', + value: _priorityGroups.contains( + BacklogBoardPriorityFilter.high, + ), + onChanged: (value) { + _toggleSet( + _priorityGroups, + BacklogBoardPriorityFilter.high, + value, + ); + }, + ), + _checkbox( + label: 'Medium priority', + value: _priorityGroups.contains( + BacklogBoardPriorityFilter.medium, + ), + onChanged: (value) { + _toggleSet( + _priorityGroups, + BacklogBoardPriorityFilter.medium, + value, + ); + }, + ), + _checkbox( + label: 'Low priority', + value: _priorityGroups.contains( + BacklogBoardPriorityFilter.low, + ), + onChanged: (value) { + _toggleSet( + _priorityGroups, + BacklogBoardPriorityFilter.low, + value, + ); + }, + ), + ], + ), + _FilterSection( + title: 'Project', + children: widget.projectOptions.isEmpty + ? const [ + Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No projects loaded', + style: TextStyle( + color: FocusFlowTokens.textMuted, + ), + ), + ), + ] + : [ + for (final project in widget.projectOptions) + _checkbox( + label: project.name, + dotColor: + BacklogBoardVisualTokens.projectColor( + project.colorToken, + ), + value: _projectIds.contains( + project.projectId, + ), + onChanged: (value) { + _toggleSet( + _projectIds, + project.projectId, + value, + ); + }, + ), + ], + ), + _FilterSection( + title: 'Duration', + children: [ + _checkbox( + label: '0-30 min', + value: _durationBuckets.contains( + BacklogBoardDurationFilter.zeroToThirty, + ), + onChanged: (value) { + _toggleSet( + _durationBuckets, + BacklogBoardDurationFilter.zeroToThirty, + value, + ); + }, + ), + _checkbox( + label: '31-60 min', + value: _durationBuckets.contains( + BacklogBoardDurationFilter.thirtyToSixty, + ), + onChanged: (value) { + _toggleSet( + _durationBuckets, + BacklogBoardDurationFilter.thirtyToSixty, + value, + ); + }, + ), + _checkbox( + label: '61-120 min', + value: _durationBuckets.contains( + BacklogBoardDurationFilter.sixtyToOneTwenty, + ), + onChanged: (value) { + _toggleSet( + _durationBuckets, + BacklogBoardDurationFilter.sixtyToOneTwenty, + value, + ); + }, + ), + _checkbox( + label: '120+ min', + value: _durationBuckets.contains( + BacklogBoardDurationFilter.overOneTwenty, + ), + onChanged: (value) { + _toggleSet( + _durationBuckets, + BacklogBoardDurationFilter.overOneTwenty, + value, + ); + }, + ), + _checkbox( + label: 'Missing duration', + value: _missingDurationOnly, + onChanged: (value) { + setState(() { + _missingDurationOnly = value; + }); + }, + ), + ], + ), + _FilterSection( + title: 'Column', + children: [ + for (final bucket in BacklogBoardBucket.values) + _checkbox( + label: _bucketLabel(bucket), + dotColor: BacklogBoardVisualTokens.bucketAccent( + bucket, + ), + value: _buckets.contains(bucket), + onChanged: (value) { + _toggleSet(_buckets, bucket, value); + }, + ), + ], + ), + _FilterSection( + title: 'Cleanup', + children: [ + _checkbox( + label: 'No reward set', + value: _noRewardSetOnly, + onChanged: (value) { + setState(() { + _noRewardSetOnly = value; + }); + }, + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () { + Navigator.of(context).pop( + _FilterDialogResult( + legacyFilters: const [], + boardFilters: + const BacklogBoardFilterSelection.empty(), + ), + ); + }, + child: const Text('Clear'), + ), + const Spacer(), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + Navigator.of(context).pop(_result()); + }, + style: FilledButton.styleFrom( + backgroundColor: FocusFlowTokens.accentMagenta, + foregroundColor: FocusFlowTokens.textPrimary, + ), + child: const Text('Apply'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// Builds a styled filter checkbox row. + Widget _checkbox({ + required String label, + required bool value, + required ValueChanged onChanged, + Color? dotColor, + }) { + return Material( + type: MaterialType.transparency, + child: CheckboxListTile( + dense: true, + value: value, + onChanged: (next) { + onChanged(next ?? false); + }, + controlAffinity: ListTileControlAffinity.leading, + activeColor: FocusFlowTokens.accentMagenta, + checkColor: FocusFlowTokens.textPrimary, + contentPadding: EdgeInsets.zero, + title: Row( + children: [ + if (dotColor != null) ...[ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + label, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + ), + ), + ], + ), + ), + ); + } + + /// Toggles [value] in [set] and rebuilds the dialog. + void _toggleSet(Set set, T value, bool selected) { + setState(() { + if (selected) { + set.add(value); + } else { + set.remove(value); + } + }); + } + + /// Builds the immutable result for the current dialog selections. + _FilterDialogResult _result() { + return _FilterDialogResult( + legacyFilters: [if (_noRewardSetOnly) BacklogFilter.noRewardSet], + boardFilters: BacklogBoardFilterSelection( + priorityGroups: _priorityGroups, + projectIds: _projectIds, + durationBuckets: _durationBuckets, + buckets: _buckets, + missingDurationOnly: _missingDurationOnly, + ), + ); + } +} + +/// Section wrapper inside the filter dialog. +class _FilterSection extends StatelessWidget { + /// Creates a filter section. + const _FilterSection({required this.title, required this.children}); + + /// Section title. + final String title; + + /// Section children. + final List children; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.72), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + ...children, + ], + ), + ), + ), + ); + } +} + +/// Opens the filter dialog and applies the returned result. +Future _showFilterDialog( + BuildContext context, + BacklogBoardController controller, +) async { + final result = await showDialog<_FilterDialogResult>( + context: context, + builder: (context) { + return _BacklogFilterDialog( + initialLegacyFilters: controller.filters, + initialBoardFilters: controller.boardFilters, + projectOptions: _projectOptionsForState(controller.state), + ); + }, + ); + if (result == null) { + return; + } + await controller.updateFilterState( + filters: result.legacyFilters, + boardFilters: result.boardFilters, + ); +} + +/// Returns project options from the current ready or empty board state. +List _projectOptionsForState( + BacklogBoardScreenState state, +) { + return switch (state) { + BacklogBoardReady(:final data) => data.board.projectOptions, + BacklogBoardEmpty(:final data) => data.board.projectOptions, + BacklogBoardLoading() || + BacklogBoardFailure() => const [], + }; +} + +/// Display label for [sortKey]. +String _sortLabel(BacklogSortKey sortKey) { + return switch (sortKey) { + BacklogSortKey.custom => 'Custom', + BacklogSortKey.priority => 'Priority', + BacklogSortKey.rewardVsEffort => 'Reward', + BacklogSortKey.age => 'Age', + BacklogSortKey.project => 'Project', + BacklogSortKey.timesPushed => 'Pushed', + BacklogSortKey.duration => 'Duration', + }; +} + +/// Display label for [groupMode]. +String _groupLabel(BacklogBoardGroupMode groupMode) { + return switch (groupMode) { + BacklogBoardGroupMode.none => 'None', + BacklogBoardGroupMode.project => 'Project', + BacklogBoardGroupMode.priority => 'Priority', + BacklogBoardGroupMode.duration => 'Duration', + }; +} + +/// Display label for [bucket]. +String _bucketLabel(BacklogBoardBucket bucket) { + return switch (bucket) { + BacklogBoardBucket.doNext => 'Do Next', + BacklogBoardBucket.needTimeBlock => 'Need Time Block', + BacklogBoardBucket.breakUpFirst => 'Break Up First', + BacklogBoardBucket.notNow => 'Not Now', + }; +} + +/// Sort menu options in display order. +const _sortOptions = [ + _MenuOption(value: BacklogSortKey.custom, label: 'Custom'), + _MenuOption(value: BacklogSortKey.priority, label: 'Priority'), + _MenuOption(value: BacklogSortKey.rewardVsEffort, label: 'Reward vs Effort'), + _MenuOption(value: BacklogSortKey.age, label: 'Age'), + _MenuOption(value: BacklogSortKey.project, label: 'Project'), + _MenuOption(value: BacklogSortKey.timesPushed, label: 'Times Pushed'), + _MenuOption(value: BacklogSortKey.duration, label: 'Duration'), +]; + +/// Group menu options in display order. +const _groupOptions = [ + _MenuOption(value: BacklogBoardGroupMode.none, label: 'None'), + _MenuOption(value: BacklogBoardGroupMode.project, label: 'Project'), + _MenuOption(value: BacklogBoardGroupMode.priority, label: 'Priority'), + _MenuOption(value: BacklogBoardGroupMode.duration, label: 'Duration'), +]; diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart new file mode 100644 index 0000000..09d7b09 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart @@ -0,0 +1,1346 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders the Backlog board screen shell for the FocusFlow Flutter UI. +library; + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../controllers/backlog_board_controller.dart'; +import '../../controllers/scheduler_command_controller.dart'; +import '../../models/backlog/backlog_board_screen_data.dart'; +import '../../theme/focus_flow_tokens.dart'; +import 'backlog_board_column.dart'; +import 'backlog_board_controls.dart'; +import 'backlog_summary_panel.dart'; +import 'backlog_task_drawer.dart'; + +/// Backlog board screen backed by a public scheduler read controller. +class BacklogBoardScreen extends StatelessWidget { + /// Creates a Backlog board screen. + const BacklogBoardScreen({ + required this.controller, + this.commandController, + super.key, + }); + + /// Controller that owns Backlog board read and selection state. + final BacklogBoardController controller; + + /// Optional command controller used by Backlog actions. + final SchedulerCommandController? commandController; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _screenAnimation(controller, commandController), + builder: (context, _) { + final commandState = + commandController?.state ?? const SchedulerCommandIdle(); + void scheduleSelectedTask() { + final detail = controller.selectedTaskDetail; + if (detail == null) { + return; + } + unawaited( + _scheduleSelectedBacklogTask( + detail: detail, + boardController: controller, + commandController: commandController, + ), + ); + } + + void scheduleSuggestedSlot(SuggestedSlotReadModel slot) { + final detail = controller.selectedTaskDetail; + if (detail == null) { + return; + } + unawaited( + _scheduleSelectedBacklogTaskAtSuggestedSlot( + detail: detail, + slot: slot, + boardController: controller, + commandController: commandController, + ), + ); + } + + void breakUpSelectedTask() { + final detail = controller.selectedTaskDetail; + if (detail == null) { + return; + } + unawaited( + _breakUpSelectedBacklogTask( + context: context, + detail: detail, + boardController: controller, + commandController: commandController, + ), + ); + } + + void pushSelectedTaskToSomeday() { + final detail = controller.selectedTaskDetail; + if (detail == null) { + return; + } + unawaited( + _pushSelectedBacklogTaskToSomeday( + detail: detail, + boardController: controller, + commandController: commandController, + ), + ); + } + + void removeSelectedTask() { + final detail = controller.selectedTaskDetail; + if (detail == null) { + return; + } + unawaited( + _removeSelectedBacklogTask( + context: context, + detail: detail, + boardController: controller, + commandController: commandController, + ), + ); + } + + return switch (controller.state) { + BacklogBoardLoading() => _BacklogFrame( + taskCountLabel: '...', + controller: controller, + commandState: commandState, + onNewTask: _newTaskHandler(context, commandController), + body: const _BacklogLoadingBody(), + ), + BacklogBoardFailure(:final message) => _BacklogFrame( + taskCountLabel: '...', + controller: controller, + commandState: commandState, + onNewTask: _newTaskHandler(context, commandController), + body: _BacklogFailureBody( + message: message, + onRetry: controller.retry, + ), + ), + BacklogBoardEmpty(:final data) => _BacklogFrame( + taskCountLabel: '${data.summary.totalTaskCount} tasks', + controller: controller, + commandState: commandState, + onNewTask: _newTaskHandler(context, commandController), + body: _BacklogReadyBody( + data: data, + selectedTaskId: controller.selectedTaskId, + selectedTaskDetail: controller.selectedTaskDetail, + isDetailLoading: controller.isSelectedTaskDetailLoading, + detailErrorMessage: controller.selectedTaskDetailError, + detailActionMessage: controller.selectedTaskActionMessage, + onTaskSelected: (taskId) { + unawaited(controller.selectTask(taskId)); + }, + onCloseDrawer: controller.clearSelection, + onAddTask: _addTaskHandler(context, commandController), + onSchedule: scheduleSelectedTask, + onSuggestedSlotPressed: scheduleSuggestedSlot, + onBreakUp: breakUpSelectedTask, + onPushToSomeday: pushSelectedTaskToSomeday, + onRemove: removeSelectedTask, + isEmpty: true, + ), + ), + BacklogBoardReady(:final data) => _BacklogFrame( + taskCountLabel: '${data.summary.totalTaskCount} tasks', + controller: controller, + commandState: commandState, + onNewTask: _newTaskHandler(context, commandController), + body: _BacklogReadyBody( + data: data, + selectedTaskId: controller.selectedTaskId, + selectedTaskDetail: controller.selectedTaskDetail, + isDetailLoading: controller.isSelectedTaskDetailLoading, + detailErrorMessage: controller.selectedTaskDetailError, + detailActionMessage: controller.selectedTaskActionMessage, + onTaskSelected: (taskId) { + unawaited(controller.selectTask(taskId)); + }, + onCloseDrawer: controller.clearSelection, + onAddTask: _addTaskHandler(context, commandController), + onSchedule: scheduleSelectedTask, + onSuggestedSlotPressed: scheduleSuggestedSlot, + onBreakUp: breakUpSelectedTask, + onPushToSomeday: pushSelectedTaskToSomeday, + onRemove: removeSelectedTask, + ), + ), + }; + }, + ); + } +} + +/// Handles deferred metadata drawer action presses without mutating scheduler data. +void _handleDrawerActionPlaceholder() {} + +/// Builds the listenable used to refresh Backlog UI for reads and commands. +Listenable _screenAnimation( + BacklogBoardController controller, + SchedulerCommandController? commandController, +) { + final commands = commandController; + if (commands == null) { + return controller; + } + return Listenable.merge([controller, commands]); +} + +/// Creates the New Task handler for the current command controller. +VoidCallback? _newTaskHandler( + BuildContext context, + SchedulerCommandController? commandController, +) { + final commands = commandController; + if (commands == null) { + return null; + } + return () { + unawaited(_showNewBacklogTaskDialog(context, commands)); + }; +} + +/// Creates the Add Task handler shared by Backlog columns. +BacklogBucketAddRequested _addTaskHandler( + BuildContext context, + SchedulerCommandController? commandController, +) { + return (bucket) { + final commands = commandController; + if (commands == null) { + return; + } + unawaited(_showNewBacklogTaskDialog(context, commands)); + }; +} + +/// Opens a minimal title-first Backlog task creation dialog. +Future _showNewBacklogTaskDialog( + BuildContext context, + SchedulerCommandController commandController, +) async { + final submittedTitle = await showDialog( + context: context, + builder: (dialogContext) { + return const _NewBacklogTaskDialog(); + }, + ); + if (submittedTitle != null) { + await commandController.quickCaptureToBacklog(submittedTitle); + } +} + +/// Opens the Break Up dialog and persists child drafts through the command path. +Future _breakUpSelectedBacklogTask({ + required BuildContext context, + required BacklogTaskDetailReadModel detail, + required BacklogBoardController boardController, + required SchedulerCommandController? commandController, +}) async { + final commands = commandController; + if (commands == null) { + return; + } + final drafts = await _showBreakUpTaskDialog(context, detail); + if (drafts == null) { + return; + } + await commands.breakUpBacklogTask( + parentTaskId: detail.item.taskId, + children: drafts, + expectedUpdatedAt: detail.item.updatedAt, + ); + if (commands.state is! SchedulerCommandFailure) { + await boardController.refreshSelectedTaskDetail(); + } +} + +/// Moves the selected Backlog task to Someday through the command path. +Future _pushSelectedBacklogTaskToSomeday({ + required BacklogTaskDetailReadModel detail, + required BacklogBoardController boardController, + required SchedulerCommandController? commandController, +}) async { + final commands = commandController; + if (commands == null) { + return; + } + await commands.pushBacklogTaskToSomeday( + taskId: detail.item.taskId, + expectedUpdatedAt: detail.item.updatedAt, + ); + if (commands.state is! SchedulerCommandFailure) { + await boardController.refreshSelectedTaskDetail(); + } +} + +/// Confirms and archives the selected Backlog task through the command path. +Future _removeSelectedBacklogTask({ + required BuildContext context, + required BacklogTaskDetailReadModel detail, + required BacklogBoardController boardController, + required SchedulerCommandController? commandController, +}) async { + final commands = commandController; + if (commands == null) { + return; + } + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + backgroundColor: FocusFlowTokens.panelBackground, + title: const Text('Remove from Backlog?'), + content: const Text( + 'This hides the task from active planning. It will not schedule anything.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(false); + }, + child: const Text('Cancel'), + ), + FilledButton.icon( + key: const ValueKey('backlog-remove-confirm-button'), + onPressed: () { + Navigator.of(dialogContext).pop(true); + }, + icon: const Icon(Icons.archive_outlined), + label: const Text('Remove'), + ), + ], + ); + }, + ); + if (confirmed != true) { + return; + } + await commands.archiveBacklogTask( + taskId: detail.item.taskId, + expectedUpdatedAt: detail.item.updatedAt, + ); + if (commands.state is SchedulerCommandSuccess) { + boardController.clearSelection(); + } +} + +/// Opens the child-entry dialog used by the Break Up action. +Future?> _showBreakUpTaskDialog( + BuildContext context, + BacklogTaskDetailReadModel detail, +) { + return showDialog>( + context: context, + builder: (dialogContext) { + return _BreakUpTaskDialog(detail: detail); + }, + ); +} + +/// Dialog that collects child task drafts for a Backlog task. +class _BreakUpTaskDialog extends StatefulWidget { + /// Creates a child-entry dialog for [detail]. + const _BreakUpTaskDialog({required this.detail}); + + /// Parent task detail used for dialog title and default context. + final BacklogTaskDetailReadModel detail; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State<_BreakUpTaskDialog> createState() => _BreakUpTaskDialogState(); +} + +/// State for the Break Up child-entry dialog. +class _BreakUpTaskDialogState extends State<_BreakUpTaskDialog> { + /// Text controllers for child task titles. + final List _titleControllers = []; + + /// Text controllers for optional child duration estimates. + final List _durationControllers = []; + + /// Dialog-level validation message shown above the child rows. + String? _errorText; + + /// Initializes the dialog with the two child rows required by the V1 flow. + @override + void initState() { + super.initState(); + _addRow(); + _addRow(); + } + + /// Releases all child-row text controllers. + @override + void dispose() { + for (final controller in _titleControllers) { + controller.dispose(); + } + for (final controller in _durationControllers) { + controller.dispose(); + } + super.dispose(); + } + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: FocusFlowTokens.panelBackground, + title: const Text('Break up task'), + content: SizedBox( + width: 520, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.detail.item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 12), + if (_errorText != null) ...[ + _BreakUpDialogError(message: _errorText!), + const SizedBox(height: 12), + ], + for (var index = 0; index < _titleControllers.length; index += 1) + Padding( + padding: EdgeInsets.only( + bottom: index == _titleControllers.length - 1 ? 0 : 10, + ), + child: _BreakUpChildRow( + index: index, + titleController: _titleControllers[index], + durationController: _durationControllers[index], + canRemove: _titleControllers.length > 2, + onRemove: () { + _removeRow(index); + }, + ), + ), + const SizedBox(height: 14), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + key: const ValueKey('backlog-break-up-add-row-button'), + onPressed: _addEditableRow, + icon: const Icon(Icons.add), + label: const Text('Add step'), + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + FilledButton.icon( + key: const ValueKey('backlog-break-up-save-button'), + onPressed: _submit, + icon: const Icon(Icons.call_split), + label: const Text('Save steps'), + ), + ], + ); + } + + /// Adds a row and rebuilds after the dialog is already mounted. + void _addEditableRow() { + setState(_addRow); + } + + /// Adds one child input row without triggering a rebuild by itself. + void _addRow() { + _titleControllers.add(TextEditingController()); + _durationControllers.add(TextEditingController()); + } + + /// Removes the child input row at [index]. + void _removeRow(int index) { + final title = _titleControllers.removeAt(index); + final duration = _durationControllers.removeAt(index); + title.dispose(); + duration.dispose(); + setState(() { + _errorText = null; + }); + } + + /// Validates child rows and closes the dialog with typed drafts. + void _submit() { + final drafts = []; + for (var index = 0; index < _titleControllers.length; index += 1) { + final title = _titleControllers[index].text.trim(); + final durationText = _durationControllers[index].text.trim(); + if (title.isEmpty && durationText.isEmpty) { + continue; + } + if (title.isEmpty) { + _showError('Add a title for each step with an estimate.'); + return; + } + final duration = _parseDuration(durationText); + if (durationText.isNotEmpty && duration == null) { + _showError('Use whole minutes for estimates.'); + return; + } + drafts.add( + ApplicationChildTaskDraft(title: title, durationMinutes: duration), + ); + } + if (drafts.length < 2) { + _showError('Add at least two titled steps.'); + return; + } + Navigator.of( + context, + ).pop(List.unmodifiable(drafts)); + } + + /// Shows [message] as the current dialog validation error. + void _showError(String message) { + setState(() { + _errorText = message; + }); + } + + /// Parses a positive whole-minute duration from [value]. + int? _parseDuration(String value) { + if (value.isEmpty) { + return null; + } + final parsed = int.tryParse(value); + if (parsed == null || parsed <= 0) { + return null; + } + return parsed; + } +} + +/// Error panel for Break Up dialog validation. +class _BreakUpDialogError extends StatelessWidget { + /// Creates an error panel with [message]. + const _BreakUpDialogError({required this.message}); + + /// Validation message to render. + final String message; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.warningYellow.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: FocusFlowTokens.warningYellow.withValues(alpha: 0.42), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + child: Row( + children: [ + const Icon( + Icons.info_outline, + color: FocusFlowTokens.warningYellow, + size: 18, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + message, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 12.5, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// One editable child row in the Break Up dialog. +class _BreakUpChildRow extends StatelessWidget { + /// Creates a child row for [index]. + const _BreakUpChildRow({ + required this.index, + required this.titleController, + required this.durationController, + required this.canRemove, + required this.onRemove, + }); + + /// Zero-based row index used for stable widget keys and labels. + final int index; + + /// Text controller for the child title. + final TextEditingController titleController; + + /// Text controller for optional duration minutes. + final TextEditingController durationController; + + /// Whether the remove button should be enabled for this row. + final bool canRemove; + + /// Callback that removes this row. + final VoidCallback onRemove; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final rowNumber = index + 1; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + key: ValueKey('backlog-break-up-title-field-$index'), + controller: titleController, + textInputAction: TextInputAction.next, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + decoration: InputDecoration( + labelText: 'Step $rowNumber', + hintText: 'Child task title', + ), + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 110, + child: TextField( + key: ValueKey('backlog-break-up-duration-field-$index'), + controller: durationController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + decoration: const InputDecoration( + labelText: 'Minutes', + hintText: 'Optional', + ), + ), + ), + const SizedBox(width: 6), + IconButton( + key: ValueKey('backlog-break-up-remove-row-button-$index'), + tooltip: 'Remove step', + onPressed: canRemove ? onRemove : null, + icon: const Icon(Icons.close), + ), + ], + ); + } +} + +/// Dialog that collects the title for a new Backlog task. +class _NewBacklogTaskDialog extends StatefulWidget { + /// Creates a new Backlog task dialog. + const _NewBacklogTaskDialog(); + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State<_NewBacklogTaskDialog> createState() => _NewBacklogTaskDialogState(); +} + +/// State for the new Backlog task dialog. +class _NewBacklogTaskDialogState extends State<_NewBacklogTaskDialog> { + /// Text controller for the required task title. + late final TextEditingController _titleController; + + /// Inline validation text for the title field. + String? _errorText; + + /// Initializes title input state before the dialog first builds. + @override + void initState() { + super.initState(); + _titleController = TextEditingController(); + } + + /// Releases the dialog-owned text controller. + @override + void dispose() { + _titleController.dispose(); + super.dispose(); + } + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: FocusFlowTokens.panelBackground, + title: const Text('New Backlog task'), + content: SizedBox( + width: 360, + child: TextField( + key: const ValueKey('backlog-new-task-title-field'), + controller: _titleController, + autofocus: true, + textInputAction: TextInputAction.done, + onSubmitted: (_) { + _submit(); + }, + style: const TextStyle(color: FocusFlowTokens.textPrimary), + decoration: InputDecoration( + labelText: 'Title', + errorText: _errorText, + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + FilledButton.icon( + key: const ValueKey('backlog-new-task-create-button'), + onPressed: _submit, + icon: const Icon(Icons.add), + label: const Text('Create'), + ), + ], + ); + } + + /// Validates and returns the entered title to the dialog caller. + void _submit() { + final title = _titleController.text.trim(); + if (title.isEmpty) { + setState(() { + _errorText = 'Title is required.'; + }); + return; + } + Navigator.of(context).pop(title); + } +} + +/// Schedules the selected Backlog [detail] through the application command path. +Future _scheduleSelectedBacklogTask({ + required BacklogTaskDetailReadModel detail, + required BacklogBoardController boardController, + required SchedulerCommandController? commandController, +}) async { + final commands = commandController; + if (commands == null) { + return; + } + final durationMinutes = detail.item.durationMinutes; + if (durationMinutes == null) { + boardController.showSelectedTaskActionMessage( + 'Add an estimate before scheduling.', + ); + return; + } + await commands.scheduleBacklogItem( + taskId: detail.item.taskId, + durationMinutes: durationMinutes, + expectedUpdatedAt: detail.item.updatedAt, + ); + if (commands.state is SchedulerCommandSuccess) { + boardController.clearSelection(); + } +} + +/// Schedules the selected Backlog [detail] at the exact suggested [slot]. +Future _scheduleSelectedBacklogTaskAtSuggestedSlot({ + required BacklogTaskDetailReadModel detail, + required SuggestedSlotReadModel slot, + required BacklogBoardController boardController, + required SchedulerCommandController? commandController, +}) async { + final commands = commandController; + if (commands == null) { + return; + } + await commands.scheduleBacklogItemAtSuggestedSlot( + taskId: detail.item.taskId, + scheduledStartUtc: slot.startUtc, + scheduledEndUtc: slot.endUtc, + expectedUpdatedAt: detail.item.updatedAt, + ); + if (commands.state is SchedulerCommandSuccess) { + boardController.clearSelection(); + } +} + +/// Main Backlog frame containing header, controls, body, and overlay slot. +class _BacklogFrame extends StatelessWidget { + /// Creates the Backlog frame. + const _BacklogFrame({ + required this.taskCountLabel, + required this.controller, + required this.commandState, + required this.onNewTask, + required this.body, + }); + + /// Count label displayed beside the page title. + final String taskCountLabel; + + /// Controller used by search and filter controls. + final BacklogBoardController controller; + + /// Current scheduler command state. + final SchedulerCommandState commandState; + + /// Callback for New Task, or null when commands are unavailable. + final VoidCallback? onNewTask; + + /// Body rendered below the header controls. + final Widget body; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(28, 26, 24, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _BacklogHeader(taskCountLabel: taskCountLabel), + const SizedBox(height: 22), + BacklogBoardTopControls( + controller: controller, + onNewTask: onNewTask, + ), + if (commandState is! SchedulerCommandIdle) ...[ + const SizedBox(height: 10), + _BacklogCommandStatus(state: commandState), + ], + const SizedBox(height: 18), + Expanded(child: body), + ], + ), + ), + const Positioned.fill(child: IgnorePointer(child: SizedBox.shrink())), + ], + ); + } +} + +/// Compact command state banner for Backlog actions. +class _BacklogCommandStatus extends StatelessWidget { + /// Creates a Backlog command status banner. + const _BacklogCommandStatus({required this.state}); + + /// Current scheduler command state. + final SchedulerCommandState state; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final (icon, label, color) = switch (state) { + SchedulerCommandRunning(:final label) => ( + Icons.hourglass_top, + label, + FocusFlowTokens.warningYellow, + ), + SchedulerCommandSuccess(:final message) => ( + Icons.check_circle_outline, + message, + FocusFlowTokens.flexibleGreen, + ), + SchedulerCommandFailure(:final codeLabel) => ( + Icons.info_outline, + 'Could not complete action: $codeLabel', + FocusFlowTokens.warningYellow, + ), + SchedulerCommandIdle() => ( + Icons.check_circle_outline, + '', + FocusFlowTokens.textMuted, + ), + }; + if (state is SchedulerCommandIdle) { + return const SizedBox.shrink(); + } + return DecoratedBox( + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.34)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + child: Row( + children: [ + Icon(icon, color: color, size: 17), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Header row for the Backlog board shell. +class _BacklogHeader extends StatelessWidget { + /// Creates the Backlog header. + const _BacklogHeader({required this.taskCountLabel}); + + /// Count label displayed beside the page title. + final String taskCountLabel; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12, + runSpacing: 8, + children: [ + Text( + 'Backlog', + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: FocusFlowTokens.textPrimary, + fontSize: 34, + fontWeight: FontWeight.w800, + ), + ), + _CountPill(label: taskCountLabel), + ], + ), + const SizedBox(height: 10), + Text( + 'Choose work that fits your current energy.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: FocusFlowTokens.textMuted, + ), + ), + ], + ), + ), + const SizedBox(width: 20), + const BacklogModeAndSettingsControls(), + ], + ); + } +} + +/// Body shown while the Backlog board loads. +class _BacklogLoadingBody extends StatelessWidget { + /// Creates a loading body. + const _BacklogLoadingBody(); + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return const _BacklogStatePanel( + icon: Icons.hourglass_top, + title: 'Loading backlog', + message: 'Preparing your board.', + ); + } +} + +/// Body shown when the Backlog board fails to load. +class _BacklogFailureBody extends StatelessWidget { + /// Creates a failure body. + const _BacklogFailureBody({required this.message, required this.onRetry}); + + /// Failure message or code. + final String message; + + /// Retry callback. + final Future Function() onRetry; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return _BacklogStatePanel( + icon: Icons.refresh, + title: 'Could not load backlog', + message: 'Status: $message', + action: OutlinedButton.icon( + onPressed: () { + unawaited(onRetry()); + }, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ); + } +} + +/// Ready or empty Backlog board body. +class _BacklogReadyBody extends StatelessWidget { + /// Creates a ready body. + const _BacklogReadyBody({ + required this.data, + required this.selectedTaskId, + required this.selectedTaskDetail, + required this.isDetailLoading, + required this.detailErrorMessage, + required this.detailActionMessage, + required this.onTaskSelected, + required this.onCloseDrawer, + required this.onAddTask, + required this.onSchedule, + required this.onSuggestedSlotPressed, + required this.onBreakUp, + required this.onPushToSomeday, + required this.onRemove, + this.isEmpty = false, + }); + + /// Backlog board data. + final BacklogBoardScreenData data; + + /// Currently selected task id, if any. + final String? selectedTaskId; + + /// Loaded selected task detail, if any. + final BacklogTaskDetailReadModel? selectedTaskDetail; + + /// Whether the selected task drawer is waiting for detail data. + final bool isDetailLoading; + + /// Drawer-local detail error message. + final String? detailErrorMessage; + + /// Drawer-local selected-task action message. + final String? detailActionMessage; + + /// Callback invoked when a task card is selected. + final BacklogTaskSelected onTaskSelected; + + /// Callback invoked when the drawer closes. + final VoidCallback onCloseDrawer; + + /// Callback invoked when a column add-task control is pressed. + final BacklogBucketAddRequested onAddTask; + + /// Callback invoked when the drawer Schedule action is pressed. + final VoidCallback onSchedule; + + /// Callback invoked when a drawer suggested slot is pressed. + final ValueChanged onSuggestedSlotPressed; + + /// Callback invoked when the drawer Break Up action is pressed. + final VoidCallback onBreakUp; + + /// Callback invoked when the drawer Push to Someday action is pressed. + final VoidCallback onPushToSomeday; + + /// Callback invoked when the drawer Remove action is pressed. + final VoidCallback onRemove; + + /// Whether the board has no tasks. + final bool isEmpty; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + fit: StackFit.expand, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: _BacklogBoardColumns( + columns: data.columns, + selectedTaskId: selectedTaskId, + onTaskSelected: onTaskSelected, + onAddTask: onAddTask, + ), + ), + const SizedBox(width: 16), + SizedBox( + width: 292, + child: isEmpty + ? const _BacklogStatePanel( + icon: Icons.check_circle_outline, + title: 'Backlog is clear', + message: 'New tasks will appear here.', + ) + : BacklogSummaryPanel(summary: data.summary), + ), + ], + ), + if (selectedTaskId != null) + Positioned( + top: 0, + right: 0, + bottom: 0, + width: _drawerWidthFor(constraints.maxWidth), + child: BacklogTaskDrawer( + key: const ValueKey('backlog-task-drawer'), + selectedTaskId: selectedTaskId, + detail: selectedTaskDetail, + loading: isDetailLoading, + errorMessage: detailErrorMessage, + actionMessage: detailActionMessage, + onClose: onCloseDrawer, + onSchedule: onSchedule, + onSuggestedSlotPressed: onSuggestedSlotPressed, + onBreakUp: onBreakUp, + onPushToSomeday: onPushToSomeday, + onRemove: onRemove, + onProjectPressed: _handleDrawerActionPlaceholder, + onAddTagPressed: _handleDrawerActionPlaceholder, + onViewDayPressed: _handleDrawerActionPlaceholder, + ), + ), + ], + ); + }, + ); + } +} + +/// Calculates the drawer width for [availableWidth] while leaving board context visible. +double _drawerWidthFor(double availableWidth) { + const preferredWidth = 466.0; + const minimumDrawerWidth = 320.0; + const safeVisibleBoardWidth = 360.0; + if (!availableWidth.isFinite) { + return preferredWidth; + } + final widthAllowedByBoard = math.max( + minimumDrawerWidth, + availableWidth - safeVisibleBoardWidth, + ); + return math.min(preferredWidth, widthAllowedByBoard); +} + +/// Horizontally scrollable set of Backlog board columns. +class _BacklogBoardColumns extends StatelessWidget { + /// Creates a board column layout. + const _BacklogBoardColumns({ + required this.columns, + required this.selectedTaskId, + required this.onTaskSelected, + required this.onAddTask, + }); + + /// Columns to render in scheduler-provided display order. + final List columns; + + /// Currently selected task id, if any. + final String? selectedTaskId; + + /// Callback invoked when a task card is selected. + final BacklogTaskSelected onTaskSelected; + + /// Callback invoked when an add-task control is pressed. + final BacklogBucketAddRequested onAddTask; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + if (columns.isEmpty) { + return const SizedBox.shrink(); + } + return LayoutBuilder( + builder: (context, constraints) { + const columnGap = 10.0; + const minColumnWidth = 260.0; + const maxColumnWidth = 300.0; + final gapWidth = columnGap * (columns.length - 1); + final availableWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : maxColumnWidth * columns.length + gapWidth; + final columnWidth = ((availableWidth - gapWidth) / columns.length) + .clamp(minColumnWidth, maxColumnWidth) + .toDouble(); + final boardWidth = columnWidth * columns.length + gapWidth; + final contentWidth = boardWidth < availableWidth + ? availableWidth + : boardWidth; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: contentWidth, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < columns.length; index += 1) ...[ + SizedBox( + width: columnWidth, + child: BacklogBoardColumn( + column: columns[index], + selectedTaskId: selectedTaskId, + onTaskSelected: onTaskSelected, + onAddTask: onAddTask, + ), + ), + if (index != columns.length - 1) + const SizedBox(width: columnGap), + ], + ], + ), + ), + ); + }, + ); + } +} + +/// Reusable centered state panel. +class _BacklogStatePanel extends StatelessWidget { + /// Creates a state panel. + const _BacklogStatePanel({ + required this.icon, + required this.title, + required this.message, + this.action, + }); + + /// Leading icon. + final IconData icon; + + /// Panel title. + final String title; + + /// Panel message. + final String message; + + /// Optional action widget. + final Widget? action; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: FocusFlowTokens.accentMagenta, size: 28), + const SizedBox(height: 14), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: FocusFlowTokens.textMuted), + ), + if (action != null) ...[const SizedBox(height: 18), action!], + ], + ), + ), + ), + ), + ); + } +} + +/// Small count pill used by headers. +class _CountPill extends StatelessWidget { + /// Creates a count pill. + const _CountPill({required this.label}); + + /// Pill label. + final String label; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + const accent = FocusFlowTokens.textMuted; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_summary_panel.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_summary_panel.dart new file mode 100644 index 0000000..834cb74 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_summary_panel.dart @@ -0,0 +1,657 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders the Backlog board summary panel for the FocusFlow Flutter UI. +library; + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../theme/backlog_board_visual_tokens.dart'; +import '../../theme/focus_flow_tokens.dart'; + +/// Right-side Backlog summary panel driven by board summary read models. +class BacklogSummaryPanel extends StatefulWidget { + /// Creates a Backlog summary panel. + const BacklogSummaryPanel({required this.summary, super.key}); + + /// Summary read model to display. + final BacklogBoardSummaryReadModel summary; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State createState() => _BacklogSummaryPanelState(); +} + +/// State for the Backlog summary panel collapse control. +class _BacklogSummaryPanelState extends State { + /// Whether summary body sections are visible. + var _expanded = true; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: FocusFlowTokens.subtleBorder), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 28, + offset: const Offset(0, 10), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SummaryHeader( + expanded: _expanded, + onToggle: () { + setState(() { + _expanded = !_expanded; + }); + }, + ), + if (_expanded) ...[ + const SizedBox(height: 22), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SummaryTotals(summary: widget.summary), + const SizedBox(height: 22), + _PriorityDistribution( + distribution: widget.summary.priorityDistribution, + ), + const SizedBox(height: 18), + _ProjectDistribution( + distribution: widget.summary.projectDistribution, + ), + const SizedBox(height: 18), + _DurationDistribution( + distribution: widget.summary.durationDistribution, + ), + const SizedBox(height: 18), + _PlanningTip(text: widget.summary.planningTip), + ], + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +/// Header row for the summary panel. +class _SummaryHeader extends StatelessWidget { + /// Creates a summary header. + const _SummaryHeader({required this.expanded, required this.onToggle}); + + /// Whether body sections are visible. + final bool expanded; + + /// Callback invoked by the collapse button. + final VoidCallback onToggle; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(Icons.auto_awesome, color: FocusFlowTokens.accentMagenta), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Backlog summary', + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const ValueKey('backlog-summary-collapse-button'), + tooltip: expanded ? 'Collapse summary' : 'Expand summary', + onPressed: onToggle, + icon: Icon(expanded ? Icons.expand_less : Icons.expand_more), + color: FocusFlowTokens.textMuted, + visualDensity: VisualDensity.compact, + ), + ], + ); + } +} + +/// Total task and estimated-time metrics. +class _SummaryTotals extends StatelessWidget { + /// Creates summary totals. + const _SummaryTotals({required this.summary}); + + /// Summary data. + final BacklogBoardSummaryReadModel summary; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _SummaryMetric( + value: '${summary.totalTaskCount}', + label: 'Total tasks', + ), + ), + Expanded( + child: _SummaryMetric( + value: _estimatedTimeText(summary.totalEstimatedMinutes), + label: 'Total estimated time', + ), + ), + ], + ); + } +} + +/// One numeric metric in the summary panel. +class _SummaryMetric extends StatelessWidget { + /// Creates a summary metric. + const _SummaryMetric({required this.value, required this.label}); + + /// Metric value. + final String value; + + /// Metric label. + final String label; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 24, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle( + color: FocusFlowTokens.textFaint, + fontSize: 11.5, + ), + ), + ], + ); + } +} + +/// Priority distribution section. +class _PriorityDistribution extends StatelessWidget { + /// Creates a priority distribution section. + const _PriorityDistribution({required this.distribution}); + + /// Distribution read model. + final BacklogDistributionReadModel distribution; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return _DistributionSection( + title: 'By priority', + children: [ + for (final entry in distribution.entries) + _DistributionRow( + leading: Icon( + Icons.flag, + color: BacklogBoardVisualTokens.priorityColorToken( + entry.colorToken, + ), + size: 15, + ), + label: entry.label, + count: entry.count, + ), + ], + ); + } +} + +/// Project distribution section. +class _ProjectDistribution extends StatelessWidget { + /// Creates a project distribution section. + const _ProjectDistribution({required this.distribution}); + + /// Distribution read model. + final BacklogDistributionReadModel distribution; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return _DistributionSection( + title: 'By project', + children: [ + for (final entry in distribution.entries) + _DistributionRow( + leading: _ColorDot( + color: BacklogBoardVisualTokens.projectColor( + entry.colorToken ?? entry.id, + ), + ), + label: entry.label, + count: entry.count, + ), + ], + ); + } +} + +/// Shared distribution section shell. +class _DistributionSection extends StatelessWidget { + /// Creates a distribution section. + const _DistributionSection({required this.title, required this.children}); + + /// Section title. + final String title; + + /// Distribution row widgets. + final List children; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 9), + if (children.isEmpty) + const Text( + 'No tasks', + style: TextStyle(color: FocusFlowTokens.textFaint, fontSize: 12), + ) + else + ...children, + ], + ); + } +} + +/// One compact distribution row. +class _DistributionRow extends StatelessWidget { + /// Creates a distribution row. + const _DistributionRow({ + required this.leading, + required this.label, + required this.count, + }); + + /// Leading icon or dot. + final Widget leading; + + /// Row label. + final String label; + + /// Row count. + final int count; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + SizedBox(width: 18, child: Center(child: leading)), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 13, + ), + ), + ), + Text( + '$count', + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +/// Colored project dot. +class _ColorDot extends StatelessWidget { + /// Creates a color dot. + const _ColorDot({required this.color}); + + /// Dot color. + final Color color; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Container( + width: 9, + height: 9, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } +} + +/// Duration donut chart and legend section. +class _DurationDistribution extends StatelessWidget { + /// Creates a duration distribution section. + const _DurationDistribution({required this.distribution}); + + /// Distribution read model. + final BacklogDistributionReadModel distribution; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final entries = _durationEntries(distribution); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Duration distribution', + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + const Text( + 'Estimated time', + style: TextStyle(color: FocusFlowTokens.textFaint, fontSize: 11.5), + ), + const SizedBox(height: 12), + Row( + children: [ + SizedBox( + width: 88, + height: 88, + child: CustomPaint( + painter: _DurationDonutPainter(entries: entries), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + children: [ + for (final entry in entries) _DurationLegendRow(entry: entry), + ], + ), + ), + ], + ), + ], + ); + } +} + +/// One normalized duration chart entry. +class _DurationChartEntry { + /// Creates a duration chart entry. + const _DurationChartEntry({ + required this.id, + required this.label, + required this.count, + required this.percentage, + required this.color, + }); + + /// Stable entry id. + final String id; + + /// Display label. + final String label; + + /// Task count in this duration bucket. + final int count; + + /// Share of total distribution. + final double percentage; + + /// Chart color. + final Color color; +} + +/// Legend row for one duration chart entry. +class _DurationLegendRow extends StatelessWidget { + /// Creates a duration legend row. + const _DurationLegendRow({required this.entry}); + + /// Chart entry to render. + final _DurationChartEntry entry; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 7), + child: Row( + children: [ + _ColorDot(color: entry.color), + const SizedBox(width: 7), + Expanded( + child: Text( + entry.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 11.5, + ), + ), + ), + Text( + '${entry.count} (${_percentageLabel(entry.percentage)})', + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +/// Painter for the duration donut chart. +class _DurationDonutPainter extends CustomPainter { + /// Creates a duration donut painter. + const _DurationDonutPainter({required this.entries}); + + /// Entries to paint. + final List<_DurationChartEntry> entries; + + /// Performs the paint behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + void paint(Canvas canvas, Size size) { + final strokeWidth = size.shortestSide * 0.18; + final rect = Offset.zero & size; + final arcRect = rect.deflate(strokeWidth / 2); + final basePaint = Paint() + ..color = FocusFlowTokens.subtleBorder.withValues(alpha: 0.62) + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + final radius = math.min(arcRect.width, arcRect.height) / 2; + canvas.drawCircle(rect.center, radius, basePaint); + + final total = entries.fold(0, (sum, entry) => sum + entry.count); + if (total == 0) { + return; + } + + var startAngle = -math.pi / 2; + for (final entry in entries) { + if (entry.count == 0) { + continue; + } + final sweep = math.pi * 2 * entry.percentage; + final paint = Paint() + ..color = entry.color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.butt; + canvas.drawArc(arcRect, startAngle, sweep, false, paint); + startAngle += sweep; + } + } + + /// Performs the shouldRepaint behavior for this scheduler component. + /// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved. + @override + bool shouldRepaint(covariant _DurationDonutPainter oldDelegate) { + return oldDelegate.entries != entries; + } +} + +/// Planning tip card. +class _PlanningTip extends StatelessWidget { + /// Creates a planning tip card. + const _PlanningTip({required this.text}); + + /// Tip body text. + final String text; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.elevatedPanel, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.38), + ), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon( + Icons.auto_awesome, + color: FocusFlowTokens.accentMagenta, + size: 18, + ), + SizedBox(width: 8), + Text( + 'Planning tip', + style: TextStyle( + color: FocusFlowTokens.accentMagenta, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + text, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + height: 1.38, + ), + ), + ], + ), + ), + ); + } +} + +/// Builds normalized duration chart entries from [distribution]. +List<_DurationChartEntry> _durationEntries( + BacklogDistributionReadModel distribution, +) { + final byId = {for (final entry in distribution.entries) entry.id: entry}; + return [ + for (final id in const ['0-30', '30-60', '60-120', '120+']) + _DurationChartEntry( + id: id, + label: _durationLabel(id, byId[id]?.label ?? id), + count: byId[id]?.count ?? 0, + percentage: byId[id]?.percentage ?? 0, + color: BacklogBoardVisualTokens.durationColor(id), + ), + ]; +} + +/// Resolves a display label for a duration distribution [id]. +String _durationLabel(String id, String fallback) { + return switch (id) { + '0-30' => '0-30 min', + '30-60' => '30-60 min', + '60-120' => '60-120 min', + '120+' => '120+ min', + _ => fallback, + }; +} + +/// Formats [minutes] as compact hour/minute text. +String _estimatedTimeText(int minutes) { + final hours = minutes ~/ 60; + final remainder = minutes % 60; + if (hours == 0) { + return '${remainder}m'; + } + if (remainder == 0) { + return '${hours}h'; + } + return '${hours}h ${remainder}m'; +} + +/// Formats [percentage] for legend display. +String _percentageLabel(double percentage) { + return '${(percentage * 100).round()}%'; +} diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_card.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_card.dart new file mode 100644 index 0000000..cb006b4 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_card.dart @@ -0,0 +1,516 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders Backlog board task cards for the FocusFlow Flutter UI. +library; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../theme/backlog_board_visual_tokens.dart'; +import '../../theme/focus_flow_tokens.dart'; +import '../timeline/difficulty_bars.dart'; +import '../timeline/reward_icon.dart'; + +/// Card widget for one Backlog board task read model. +class BacklogTaskCard extends StatelessWidget { + /// Creates a Backlog task card. + const BacklogTaskCard({ + required this.item, + required this.selected, + required this.onTap, + super.key, + }); + + /// Read model that supplies all card content. + final BacklogBoardItemReadModel item; + + /// Whether this card is the current UI selection. + final bool selected; + + /// Callback invoked when the user selects the card. + final VoidCallback onTap; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final accent = BacklogBoardVisualTokens.bucketAccent(item.bucket); + final subtitle = item.subtitle ?? item.bucketReason; + return Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey('backlog-card-${item.taskId}'), + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: AnimatedContainer( + key: ValueKey('backlog-card-container-${item.taskId}'), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + padding: const EdgeInsets.fromLTRB(10, 10, 10, 11), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + FocusFlowTokens.elevatedPanel, + accent.withValues(alpha: 0.06), + ], + ), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected + ? FocusFlowTokens.accentMagenta + : FocusFlowTokens.subtleBorder, + width: selected ? 2 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CardTitleRow(item: item, selected: selected), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 5), + Padding( + padding: const EdgeInsets.only(left: 24), + child: Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 11, + height: 1.22, + ), + ), + ), + ], + const SizedBox(height: 10), + _CardMetadataRow(item: item), + const SizedBox(height: 10), + Divider( + height: 1, + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.55), + ), + const SizedBox(height: 9), + _CardEffortRow(item: item, accent: accent), + if (item.childPreview.isNotEmpty) ...[ + const SizedBox(height: 10), + _ChildPreviewSection(children: item.childPreview), + ], + ], + ), + ), + ), + ); + } +} + +/// Header row with checkbox-like selection square and task title. +class _CardTitleRow extends StatelessWidget { + /// Creates a title row for [item]. + const _CardTitleRow({required this.item, required this.selected}); + + /// Task card data to display. + final BacklogBoardItemReadModel item; + + /// Whether this card is selected. + final bool selected; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 15, + height: 15, + margin: const EdgeInsets.only(top: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3), + border: Border.all( + color: selected + ? FocusFlowTokens.accentMagenta + : FocusFlowTokens.textFaint, + width: selected ? 1.8 : 1.2, + ), + color: selected + ? FocusFlowTokens.accentMagenta.withValues(alpha: 0.12) + : Colors.transparent, + ), + child: selected + ? const Icon( + Icons.check, + color: FocusFlowTokens.accentMagenta, + size: 11, + ) + : null, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 13.5, + fontWeight: FontWeight.w800, + height: 1.18, + ), + ), + ), + ], + ); + } +} + +/// Row of duration, project, and priority metadata. +class _CardMetadataRow extends StatelessWidget { + /// Creates a metadata row for [item]. + const _CardMetadataRow({required this.item}); + + /// Task card data to display. + final BacklogBoardItemReadModel item; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final projectColor = BacklogBoardVisualTokens.projectColor( + item.projectColorToken, + ); + final priorityColor = BacklogBoardVisualTokens.priorityColor(item.priority); + return Wrap( + spacing: 12, + runSpacing: 7, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _IconMetadata( + icon: Icons.schedule, + color: FocusFlowTokens.textMuted, + label: _durationLabel(item.durationMinutes), + ), + _DotMetadata(color: projectColor, label: item.projectName), + _IconMetadata( + icon: Icons.flag, + color: priorityColor, + label: BacklogBoardVisualTokens.priorityLabel(item.priority), + ), + ], + ); + } +} + +/// Compact metadata item with a Material icon. +class _IconMetadata extends StatelessWidget { + /// Creates icon metadata. + const _IconMetadata({ + required this.icon, + required this.color, + required this.label, + }); + + /// Icon shown before the label. + final IconData icon; + + /// Color used by the icon. + final Color color; + + /// Display label. + final String label; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 13), + const SizedBox(width: 5), + Text( + label, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +/// Compact metadata item with a color dot. +class _DotMetadata extends StatelessWidget { + /// Creates dot metadata. + const _DotMetadata({required this.color, required this.label}); + + /// Dot color. + final Color color; + + /// Display label. + final String label; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +/// Footer row with difficulty bars and reward marks. +class _CardEffortRow extends StatelessWidget { + /// Creates an effort row for [item]. + const _CardEffortRow({required this.item, required this.accent}); + + /// Task card data to display. + final BacklogBoardItemReadModel item; + + /// Bucket accent used for compact indicators. + final Color accent; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _MetricCluster( + label: 'Difficulty', + child: DifficultyBars.forLevel( + difficulty: item.difficulty, + color: accent, + size: const Size(36, 18), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _MetricCluster( + label: 'Reward', + alignment: MainAxisAlignment.end, + child: _RewardMarks(reward: item.reward, color: accent), + ), + ), + ], + ); + } +} + +/// Label-and-value cluster used by the card footer. +class _MetricCluster extends StatelessWidget { + /// Creates a compact metric cluster. + const _MetricCluster({ + required this.label, + required this.child, + this.alignment = MainAxisAlignment.start, + }); + + /// Metric label. + final String label; + + /// Indicator widget. + final Widget child; + + /// Horizontal alignment for the cluster content. + final MainAxisAlignment alignment; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: alignment, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 10.5, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 6), + child, + ], + ); + } +} + +/// Solid reward marks rendered from a domain reward level. +class _RewardMarks extends StatelessWidget { + /// Creates reward marks. + const _RewardMarks({required this.reward, required this.color}); + + /// Reward level to display. + final RewardLevel reward; + + /// Mark color. + final Color color; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final count = BacklogBoardVisualTokens.rewardCount(reward); + if (count == 0) { + return const Text( + 'None', + style: TextStyle(color: FocusFlowTokens.textFaint, fontSize: 10.5), + ); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < count; index += 1) + Padding( + padding: EdgeInsets.only(left: index == 0 ? 0 : 1.5), + child: RewardIcon(color: color, size: 11), + ), + ], + ); + } +} + +/// Nested child preview rows for parent Backlog cards. +class _ChildPreviewSection extends StatelessWidget { + /// Creates a child preview section. + const _ChildPreviewSection({required this.children}); + + /// Child preview read models. + final List children; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.75), + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only(left: 10), + child: Column( + children: [ + for (final child in children) + _ChildPreviewRow( + key: ValueKey('backlog-child-${child.taskId}'), + child: child, + ), + ], + ), + ), + ); + } +} + +/// One nested child preview row inside a Backlog task card. +class _ChildPreviewRow extends StatelessWidget { + /// Creates a child preview row. + const _ChildPreviewRow({required this.child, super.key}); + + /// Child preview data. + final BacklogChildPreviewReadModel child; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 7), + child: Row( + children: [ + Icon( + child.isCompleted + ? Icons.check_box_outlined + : Icons.check_box_outline_blank, + color: FocusFlowTokens.textFaint, + size: 14, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + child.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: child.isCompleted + ? FocusFlowTokens.completedText + : FocusFlowTokens.textMuted, + fontSize: 11, + decoration: child.isCompleted + ? TextDecoration.lineThrough + : TextDecoration.none, + ), + ), + ), + if (child.durationMinutes != null) ...[ + const SizedBox(width: 8), + _IconMetadata( + icon: Icons.schedule, + color: FocusFlowTokens.textFaint, + label: _durationLabel(child.durationMinutes), + ), + ], + ], + ), + ); + } +} + +/// Formats [durationMinutes] as compact card metadata. +String _durationLabel(int? durationMinutes) { + if (durationMinutes == null) { + return 'No estimate'; + } + if (durationMinutes < 60) { + return '$durationMinutes min'; + } + final hours = durationMinutes ~/ 60; + final minutes = durationMinutes % 60; + if (minutes == 0) { + return '${hours}h'; + } + return '${hours}h ${minutes}m'; +} diff --git a/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart new file mode 100644 index 0000000..b315c58 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart @@ -0,0 +1,1311 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders the right-anchored Backlog task detail drawer. +library; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../theme/backlog_board_visual_tokens.dart'; +import '../../theme/focus_flow_tokens.dart'; +import '../timeline/difficulty_bars.dart'; +import '../timeline/reward_icon.dart'; + +/// Right-side detail drawer for the currently selected Backlog task. +class BacklogTaskDrawer extends StatelessWidget { + /// Creates a Backlog task detail drawer. + const BacklogTaskDrawer({ + required this.selectedTaskId, + required this.detail, + required this.loading, + required this.errorMessage, + required this.actionMessage, + required this.onClose, + required this.onSchedule, + required this.onBreakUp, + required this.onPushToSomeday, + required this.onRemove, + required this.onSuggestedSlotPressed, + required this.onProjectPressed, + required this.onAddTagPressed, + required this.onViewDayPressed, + super.key, + }); + + /// Selected task id while the drawer is open. + final String? selectedTaskId; + + /// Loaded task detail, if available. + final BacklogTaskDetailReadModel? detail; + + /// Whether the drawer should show a loading body. + final bool loading; + + /// Drawer-local error message for a failed detail read. + final String? errorMessage; + + /// Drawer-local action message, such as a missing-duration prompt. + final String? actionMessage; + + /// Callback that closes the drawer and clears selection. + final VoidCallback onClose; + + /// Callback for the primary Schedule action. + final VoidCallback onSchedule; + + /// Callback for the Break Up action. + final VoidCallback onBreakUp; + + /// Callback for the Push to Someday action. + final VoidCallback onPushToSomeday; + + /// Callback for the Remove action. + final VoidCallback onRemove; + + /// Callback for scheduling one suggested slot. + final ValueChanged onSuggestedSlotPressed; + + /// Callback for the project selector placeholder. + final VoidCallback onProjectPressed; + + /// Callback for the tag add placeholder. + final VoidCallback onAddTagPressed; + + /// Callback for the suggested-slot View day placeholder. + final VoidCallback onViewDayPressed; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final loadedDetail = detail; + return DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF10121B), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: FocusFlowTokens.subtleBorder), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.38), + blurRadius: 36, + offset: const Offset(-10, 0), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 18), + child: loadedDetail == null + ? _DrawerStatusContent( + selectedTaskId: selectedTaskId, + loading: loading, + errorMessage: errorMessage, + onClose: onClose, + ) + : _DrawerDetailContent( + detail: loadedDetail, + onClose: onClose, + onSchedule: onSchedule, + onBreakUp: onBreakUp, + onPushToSomeday: onPushToSomeday, + onRemove: onRemove, + onSuggestedSlotPressed: onSuggestedSlotPressed, + onProjectPressed: onProjectPressed, + onAddTagPressed: onAddTagPressed, + onViewDayPressed: onViewDayPressed, + actionMessage: actionMessage, + ), + ), + ); + } +} + +/// Status body shown before detail is available or after a detail error. +class _DrawerStatusContent extends StatelessWidget { + /// Creates a drawer status content body. + const _DrawerStatusContent({ + required this.selectedTaskId, + required this.loading, + required this.errorMessage, + required this.onClose, + }); + + /// Selected task id used in loading copy. + final String? selectedTaskId; + + /// Whether the detail read is still in progress. + final bool loading; + + /// Error message for failed reads. + final String? errorMessage; + + /// Callback that closes the drawer. + final VoidCallback onClose; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DrawerTopRow( + title: loading ? 'Loading task' : 'Could not load task details', + onClose: onClose, + ), + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (loading) + const SizedBox.square( + dimension: 30, + child: CircularProgressIndicator(strokeWidth: 2.4), + ) + else + const Icon( + Icons.info_outline, + color: FocusFlowTokens.warningYellow, + size: 30, + ), + const SizedBox(height: 14), + Text( + loading + ? 'Preparing ${selectedTaskId ?? 'task'} details.' + : errorMessage ?? 'The selected task is unavailable.', + textAlign: TextAlign.center, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + height: 1.35, + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +/// Fully loaded drawer content for one Backlog task. +class _DrawerDetailContent extends StatelessWidget { + /// Creates loaded drawer content. + const _DrawerDetailContent({ + required this.detail, + required this.onClose, + required this.onSchedule, + required this.onBreakUp, + required this.onPushToSomeday, + required this.onRemove, + required this.onSuggestedSlotPressed, + required this.onProjectPressed, + required this.onAddTagPressed, + required this.onViewDayPressed, + required this.actionMessage, + }); + + /// Detail read model supplied by the scheduler application layer. + final BacklogTaskDetailReadModel detail; + + /// Callback that closes the drawer. + final VoidCallback onClose; + + /// Callback for Schedule. + final VoidCallback onSchedule; + + /// Callback for Break Up. + final VoidCallback onBreakUp; + + /// Callback for Push to Someday. + final VoidCallback onPushToSomeday; + + /// Callback for Remove. + final VoidCallback onRemove; + + /// Callback for scheduling one suggested slot. + final ValueChanged onSuggestedSlotPressed; + + /// Callback for the project selector placeholder. + final VoidCallback onProjectPressed; + + /// Callback for the tag add placeholder. + final VoidCallback onAddTagPressed; + + /// Callback for View day. + final VoidCallback onViewDayPressed; + + /// Drawer-local action message. + final String? actionMessage; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DrawerHeader(detail: detail, onClose: onClose), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _NotesSection(notes: detail.notes), + const SizedBox(height: 16), + _ProjectAndTagsSection( + item: detail.item, + tags: detail.tags, + onProjectPressed: onProjectPressed, + onAddTagPressed: onAddTagPressed, + ), + const SizedBox(height: 16), + _MetricTiles(item: detail.item), + const SizedBox(height: 16), + _SuggestedSlotsSection( + slots: detail.suggestedSlots, + unavailableReason: detail.suggestedSlotUnavailableReason, + onSuggestedSlotPressed: onSuggestedSlotPressed, + onViewDayPressed: onViewDayPressed, + ), + ], + ), + ), + ), + const SizedBox(height: 18), + if (actionMessage != null) ...[ + _DrawerActionMessage(message: actionMessage!), + const SizedBox(height: 10), + ], + _DrawerActions( + onSchedule: onSchedule, + onBreakUp: onBreakUp, + onPushToSomeday: onPushToSomeday, + onRemove: onRemove, + ), + ], + ); + } +} + +/// Drawer-local action message panel. +class _DrawerActionMessage extends StatelessWidget { + /// Creates an action message panel. + const _DrawerActionMessage({required this.message}); + + /// Message body. + final String message; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.warningYellow.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: FocusFlowTokens.warningYellow.withValues(alpha: 0.42), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + const Icon( + Icons.info_outline, + color: FocusFlowTokens.warningYellow, + size: 18, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + message, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 12.5, + height: 1.3, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Shared title row with a close button. +class _DrawerTopRow extends StatelessWidget { + /// Creates a drawer top row. + const _DrawerTopRow({required this.title, required this.onClose}); + + /// Row title. + final String title; + + /// Close callback. + final VoidCallback onClose; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + key: const ValueKey('backlog-drawer-close-button'), + tooltip: 'Close task details', + onPressed: onClose, + icon: const Icon(Icons.close), + color: FocusFlowTokens.textMuted, + ), + ], + ); + } +} + +/// Header with bucket icon, title, subtitle, and age metadata. +class _DrawerHeader extends StatelessWidget { + /// Creates a drawer header. + const _DrawerHeader({required this.detail, required this.onClose}); + + /// Detail model to render. + final BacklogTaskDetailReadModel detail; + + /// Close callback. + final VoidCallback onClose; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final item = detail.item; + final accent = BacklogBoardVisualTokens.bucketAccent(item.bucket); + final subtitle = item.subtitle ?? item.bucketReason; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: accent), + ), + child: Icon( + BacklogBoardVisualTokens.bucketIcon(item.bucket), + color: accent, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + item.title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 21, + fontWeight: FontWeight.w800, + height: 1.12, + ), + ), + ), + const SizedBox(width: 8), + const Padding( + padding: EdgeInsets.only(top: 2), + child: RewardIcon( + size: 18, + color: FocusFlowTokens.accentMagenta, + ), + ), + ], + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + subtitle, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + height: 1.32, + ), + ), + ], + const SizedBox(height: 10), + Text( + detail.addedLabel, + style: const TextStyle( + color: FocusFlowTokens.textFaint, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(width: 4), + IconButton( + key: const ValueKey('backlog-drawer-close-button'), + tooltip: 'Close task details', + onPressed: onClose, + icon: const Icon(Icons.close), + color: FocusFlowTokens.textMuted, + ), + ], + ), + ], + ); + } +} + +/// Notes section for the drawer. +class _NotesSection extends StatelessWidget { + /// Creates a notes section. + const _NotesSection({required this.notes}); + + /// Optional notes body. + final String? notes; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final body = notes?.trim(); + return _SectionPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'Notes'), + const SizedBox(height: 10), + Text( + body == null || body.isEmpty ? 'No notes yet.' : body, + style: TextStyle( + color: body == null || body.isEmpty + ? FocusFlowTokens.textFaint + : FocusFlowTokens.textMuted, + height: 1.42, + ), + ), + ], + ), + ); + } +} + +/// Project and tags row. +class _ProjectAndTagsSection extends StatelessWidget { + /// Creates a project and tags section. + const _ProjectAndTagsSection({ + required this.item, + required this.tags, + required this.onProjectPressed, + required this.onAddTagPressed, + }); + + /// Selected task item data. + final BacklogBoardItemReadModel item; + + /// Detail tag labels. + final List tags; + + /// Project selector callback. + final VoidCallback onProjectPressed; + + /// Add-tag callback. + final VoidCallback onAddTagPressed; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 360) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ProjectTile(item: item, onPressed: onProjectPressed), + const SizedBox(height: 10), + _TagsTile(tags: tags, onAddTagPressed: onAddTagPressed), + ], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _ProjectTile(item: item, onPressed: onProjectPressed), + ), + const SizedBox(width: 10), + Expanded( + child: _TagsTile(tags: tags, onAddTagPressed: onAddTagPressed), + ), + ], + ); + }, + ); + } +} + +/// Project selector tile. +class _ProjectTile extends StatelessWidget { + /// Creates a project tile. + const _ProjectTile({required this.item, required this.onPressed}); + + /// Selected task item data. + final BacklogBoardItemReadModel item; + + /// Placeholder selector callback. + final VoidCallback onPressed; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return _SectionPanel( + onTap: onPressed, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'Project'), + const SizedBox(height: 13), + Row( + children: [ + _ColorDot( + color: BacklogBoardVisualTokens.projectColor( + item.projectColorToken, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.projectName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + const Icon( + Icons.keyboard_arrow_down, + color: FocusFlowTokens.textMuted, + ), + ], + ), + ], + ), + ); + } +} + +/// Tags tile with read-only chips and add placeholder. +class _TagsTile extends StatelessWidget { + /// Creates a tags tile. + const _TagsTile({required this.tags, required this.onAddTagPressed}); + + /// Detail tag labels. + final List tags; + + /// Add-tag callback. + final VoidCallback onAddTagPressed; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return _SectionPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'Tags'), + const SizedBox(height: 10), + Wrap( + spacing: 7, + runSpacing: 7, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (tags.isEmpty) + const Text( + 'No tags', + style: TextStyle( + color: FocusFlowTokens.textFaint, + fontSize: 12, + ), + ) + else + for (final tag in tags) _TagChip(label: tag), + _SmallIconButton( + key: const ValueKey('backlog-drawer-add-tag-button'), + tooltip: 'Add tag', + icon: Icons.add, + onPressed: onAddTagPressed, + ), + ], + ), + ], + ), + ); + } +} + +/// Metric tile row for duration, reward, and difficulty. +class _MetricTiles extends StatelessWidget { + /// Creates metric tiles. + const _MetricTiles({required this.item}); + + /// Selected task item data. + final BacklogBoardItemReadModel item; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final tileWidth = (constraints.maxWidth - 16) / 3; + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + SizedBox( + width: tileWidth, + child: _MetricTile( + title: 'Duration', + leading: const Icon( + Icons.schedule, + color: FocusFlowTokens.textMuted, + size: 18, + ), + value: _durationLabel(item.durationMinutes), + caption: item.durationMinutes == null + ? 'Add estimate' + : 'Estimated', + ), + ), + SizedBox( + width: tileWidth, + child: _MetricTile( + title: 'Reward', + leading: const RewardIcon( + size: 17, + color: FocusFlowTokens.accentMagenta, + ), + value: _rewardValue(item.reward), + caption: _rewardLabel(item.reward), + ), + ), + SizedBox( + width: tileWidth, + child: _MetricTile( + title: 'Difficulty', + leading: DifficultyBars.forLevel( + difficulty: item.difficulty, + color: BacklogBoardVisualTokens.bucketAccent(item.bucket), + size: const Size(20, 16), + ), + value: _difficultyValue(item.difficulty), + caption: _difficultyLabel(item.difficulty), + ), + ), + ], + ); + }, + ); + } +} + +/// One compact drawer metric tile. +class _MetricTile extends StatelessWidget { + /// Creates a metric tile. + const _MetricTile({ + required this.title, + required this.leading, + required this.value, + required this.caption, + }); + + /// Tile title. + final String title; + + /// Leading icon widget. + final Widget leading; + + /// Primary value. + final String value; + + /// Secondary caption. + final String caption; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return _SectionPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + leading, + const SizedBox(width: 7), + Flexible( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + caption, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 12, + ), + ), + ], + ), + ); + } +} + +/// Suggested slots section. +class _SuggestedSlotsSection extends StatelessWidget { + /// Creates a suggested slots section. + const _SuggestedSlotsSection({ + required this.slots, + required this.unavailableReason, + required this.onSuggestedSlotPressed, + required this.onViewDayPressed, + }); + + /// Suggested slot read models. + final List slots; + + /// Optional reason why suggestions are unavailable. + final String? unavailableReason; + + /// Callback for scheduling a suggested slot. + final ValueChanged onSuggestedSlotPressed; + + /// View-day callback. + final VoidCallback onViewDayPressed; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return _SectionPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Expanded(child: _SectionLabel(label: 'Suggested slots')), + TextButton( + key: const ValueKey('backlog-drawer-view-day-button'), + onPressed: onViewDayPressed, + child: const Text('View day'), + ), + ], + ), + const SizedBox(height: 10), + if (slots.isEmpty) + Text( + unavailableReason ?? 'No suggested slots yet.', + style: const TextStyle( + color: FocusFlowTokens.textFaint, + height: 1.35, + ), + ) + else + for (final slot in slots) ...[ + _SuggestedSlotRow( + slot: slot, + onPressed: () { + onSuggestedSlotPressed(slot); + }, + ), + if (slot != slots.last) const SizedBox(height: 8), + ], + ], + ), + ); + } +} + +/// One suggested schedule slot row. +class _SuggestedSlotRow extends StatelessWidget { + /// Creates a suggested slot row. + const _SuggestedSlotRow({required this.slot, required this.onPressed}); + + /// Suggested slot data. + final SuggestedSlotReadModel slot; + + /// Callback that schedules this exact slot. + final VoidCallback onPressed; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final severity = _fitSeverityColor(slot.fitSeverityToken); + return Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey('backlog-drawer-suggested-slot-${slot.id}'), + borderRadius: BorderRadius.circular(8), + onTap: onPressed, + child: DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: slot.isPrimary + ? FocusFlowTokens.accentMagenta + : severity.withValues(alpha: 0.55), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + child: Row( + children: [ + const Icon( + Icons.calendar_today_outlined, + color: FocusFlowTokens.textMuted, + size: 17, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + slot.localDateLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: slot.isPrimary + ? BacklogBoardVisualTokens.doNextAccent + : FocusFlowTokens.textMuted, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + '${slot.startText} - ${slot.endText} | ${slot.durationMinutes} min', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + _FitBadge(label: slot.fitLabel, color: severity), + const SizedBox(width: 4), + const Icon( + Icons.chevron_right, + color: FocusFlowTokens.textMuted, + size: 20, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Drawer action area. +class _DrawerActions extends StatelessWidget { + /// Creates the drawer action button group. + const _DrawerActions({ + required this.onSchedule, + required this.onBreakUp, + required this.onPushToSomeday, + required this.onRemove, + }); + + /// Schedule callback. + final VoidCallback onSchedule; + + /// Break-up callback. + final VoidCallback onBreakUp; + + /// Push-to-someday callback. + final VoidCallback onPushToSomeday; + + /// Remove callback. + final VoidCallback onRemove; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: FilledButton.icon( + key: const ValueKey('backlog-drawer-schedule-button'), + onPressed: onSchedule, + icon: const Icon(Icons.check), + label: const Text('Schedule'), + style: FilledButton.styleFrom( + backgroundColor: FocusFlowTokens.accentMagenta, + foregroundColor: FocusFlowTokens.textPrimary, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + key: const ValueKey('backlog-drawer-break-up-button'), + onPressed: onBreakUp, + icon: const Icon(Icons.splitscreen), + label: const Text('Break Up'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + OutlinedButton.icon( + key: const ValueKey('backlog-drawer-push-someday-button'), + onPressed: onPushToSomeday, + icon: const Icon(Icons.arrow_forward), + label: const Text('Push to Someday'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + key: const ValueKey('backlog-drawer-remove-button'), + onPressed: onRemove, + icon: const Icon(Icons.delete_outline), + label: const Text('Remove'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ); + } +} + +/// Shared bordered section panel. +class _SectionPanel extends StatelessWidget { + /// Creates a bordered section panel. + const _SectionPanel({required this.child, this.onTap}); + + /// Panel contents. + final Widget child; + + /// Optional tap callback. + final VoidCallback? onTap; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final content = DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.82), + ), + ), + child: Padding(padding: const EdgeInsets.all(14), child: child), + ); + final callback = onTap; + if (callback == null) { + return content; + } + return Material( + color: Colors.transparent, + child: InkWell( + onTap: callback, + borderRadius: BorderRadius.circular(8), + child: content, + ), + ); + } +} + +/// Small section label. +class _SectionLabel extends StatelessWidget { + /// Creates a section label. + const _SectionLabel({required this.label}); + + /// Label text. + final String label; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w800, + ), + ); + } +} + +/// Small colored dot. +class _ColorDot extends StatelessWidget { + /// Creates a color dot. + const _ColorDot({required this.color}); + + /// Dot color. + final Color color; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } +} + +/// Read-only tag chip. +class _TagChip extends StatelessWidget { + /// Creates a tag chip. + const _TagChip({required this.label}); + + /// Tag label. + final String label; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: BacklogBoardVisualTokens.tagChipBackground(label), + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: BacklogBoardVisualTokens.tagChipForeground( + label, + ).withValues(alpha: 0.42), + ), + ), + child: Text( + label, + style: TextStyle( + color: BacklogBoardVisualTokens.tagChipForeground(label), + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +/// Compact icon button used inside chips and rows. +class _SmallIconButton extends StatelessWidget { + /// Creates a small icon button. + const _SmallIconButton({ + required this.tooltip, + required this.icon, + required this.onPressed, + super.key, + }); + + /// Tooltip text. + final String tooltip; + + /// Icon data. + final IconData icon; + + /// Press callback. + final VoidCallback onPressed; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 30, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + icon: Icon(icon), + iconSize: 17, + color: FocusFlowTokens.textMuted, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + ), + ); + } +} + +/// Fit badge for a suggested slot. +class _FitBadge extends StatelessWidget { + /// Creates a fit badge. + const _FitBadge({required this.label, required this.color}); + + /// Badge label. + final String label; + + /// Badge color. + final Color color; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(7), + border: Border.all(color: color.withValues(alpha: 0.72)), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +/// Formats [minutes] for drawer display. +String _durationLabel(int? minutes) { + return minutes == null ? 'Not set' : '$minutes min'; +} + +/// Formats [reward] as a numeric rating. +String _rewardValue(RewardLevel reward) { + final count = BacklogBoardVisualTokens.rewardCount(reward); + return count == 0 ? 'Not set' : '$count/5'; +} + +/// Formats [reward] as a plain-language label. +String _rewardLabel(RewardLevel reward) { + return switch (reward) { + RewardLevel.notSet => 'Not set', + RewardLevel.veryLow => 'Tiny', + RewardLevel.low => 'Useful', + RewardLevel.medium => 'Satisfying', + RewardLevel.high => 'Strong', + RewardLevel.veryHigh => 'High reward', + }; +} + +/// Formats [difficulty] as a numeric rating. +String _difficultyValue(DifficultyLevel difficulty) { + final count = DifficultyBars.fillCountForLevel(difficulty); + return count == 0 ? 'Not set' : '$count/5'; +} + +/// Formats [difficulty] as a plain-language label. +String _difficultyLabel(DifficultyLevel difficulty) { + return switch (difficulty) { + DifficultyLevel.notSet => 'Not set', + DifficultyLevel.veryEasy => 'Very easy', + DifficultyLevel.easy => 'Easy', + DifficultyLevel.medium => 'Medium', + DifficultyLevel.hard => 'Hard', + DifficultyLevel.veryHard => 'Very hard', + }; +} + +/// Resolves [token] to a fit-badge color. +Color _fitSeverityColor(String token) { + return switch (token) { + 'fit-great' => BacklogBoardVisualTokens.doNextAccent, + 'fit-okay' => FocusFlowTokens.warningYellow, + _ => FocusFlowTokens.textMuted, + }; +} diff --git a/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart b/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart deleted file mode 100644 index b951467..0000000 --- a/apps/focus_flow_flutter/lib/widgets/backlog_pane.dart +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-FileCopyrightText: 2026 FocusFlow contributors -// SPDX-License-Identifier: AGPL-3.0-only - -/// Renders the Backlog Pane widget for the FocusFlow Flutter UI. -library; - -import 'package:flutter/material.dart'; -import 'package:scheduler_core/scheduler_core.dart'; - -import '../controllers/scheduler_command_controller.dart'; -import '../controllers/scheduler_read_controller.dart'; -import 'read_state_view.dart'; -import 'schedule_components.dart'; - -/// Read-driven pane that renders backlog content and command controls. -class BacklogPane extends StatelessWidget { - /// Creates a backlog pane from a read [controller]. - const BacklogPane({ - required this.controller, - this.commandController, - super.key, - }); - - /// Controller that loads backlog data. - final UiReadController controller; - - /// Optional command controller for mutating backlog items. - final SchedulerCommandController? commandController; - - /// Builds the widget subtree for this component. - /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. - @override - Widget build(BuildContext context) { - return ReadStateView( - controller: controller, - title: 'Backlog', - emptyTitle: 'Backlog is clear', - emptyMessage: 'Captured tasks will appear here when they need planning.', - dataBuilder: (context, result) => - BacklogContent(result: result, commandController: commandController), - ); - } -} - -/// Populated backlog view for a loaded backlog query result. -class BacklogContent extends StatelessWidget { - /// Creates backlog content for [result]. - const BacklogContent({ - required this.result, - this.commandController, - super.key, - }); - - /// Loaded backlog result to display. - final BacklogQueryResult result; - - /// Optional command controller for capture and scheduling actions. - final SchedulerCommandController? commandController; - - /// Builds the widget subtree for this component. - /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. - @override - Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Text('Backlog', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 12), - if (commandController != null) ...[ - QuickCaptureForm(commandController: commandController!), - const SizedBox(height: 12), - CommandStatusBanner(controller: commandController!), - const SizedBox(height: 12), - ], - for (final item in result.items) - BacklogRow( - item: item, - onSchedule: commandController == null - ? null - : (durationMinutes) { - return commandController!.scheduleBacklogItem( - taskId: item.task.id, - durationMinutes: durationMinutes, - expectedUpdatedAt: item.task.updatedAt, - ); - }, - ), - ], - ); - } -} - -/// Small form for quickly capturing a task into the backlog. -class QuickCaptureForm extends StatefulWidget { - /// Creates a quick capture form. - const QuickCaptureForm({required this.commandController, super.key}); - - /// Command controller used to submit captured task titles. - final SchedulerCommandController commandController; - - /// Creates the mutable state object used by this stateful widget. - /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. - @override - State createState() => _QuickCaptureFormState(); -} - -/// Private implementation type for `_QuickCaptureFormState` in this library. -/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly. -class _QuickCaptureFormState extends State { - /// Stores the `titleController` value for this object. - /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. - final titleController = TextEditingController(); - - /// Releases resources owned by this object before it leaves the tree. - /// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks. - @override - void dispose() { - titleController.dispose(); - super.dispose(); - } - - /// Builds the widget subtree for this component. - /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. - @override - Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: TextField( - key: const ValueKey('quick-capture-title'), - controller: titleController, - decoration: const InputDecoration(labelText: 'Quick capture'), - textInputAction: TextInputAction.done, - ), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: () async { - final title = titleController.text.trim(); - if (title.isEmpty) { - return; - } - await widget.commandController.quickCaptureToBacklog(title); - titleController.clear(); - }, - icon: const Icon(Icons.add), - label: const Text('Capture'), - ), - ], - ); - } -} - -/// Displays the latest command state for backlog interactions. -class CommandStatusBanner extends StatelessWidget { - /// Creates a command status banner. - const CommandStatusBanner({required this.controller, super.key}); - - /// Command controller whose state should be rendered. - final SchedulerCommandController controller; - - /// Builds the widget subtree for this component. - /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: controller, - builder: (context, _) { - return switch (controller.state) { - SchedulerCommandIdle() => const SizedBox.shrink(), - SchedulerCommandRunning(label: final label) => Text(label), - SchedulerCommandSuccess(message: final message) => Text(message), - SchedulerCommandFailure(failure: final failure, error: final error) => - Text( - 'Command issue: ' - '${failure?.detailCode ?? failure?.code.name ?? error ?? 'unexpected'}', - ), - }; - }, - ); - } -} diff --git a/apps/focus_flow_flutter/lib/widgets/dialogs/break_up_dialog.dart b/apps/focus_flow_flutter/lib/widgets/dialogs/break_up_dialog.dart new file mode 100644 index 0000000..aa67cdb --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/dialogs/break_up_dialog.dart @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders the shared Break Up dialog for the FocusFlow Flutter UI. +library; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../theme/focus_flow_tokens.dart'; + +/// Editable child-task row state owned by [BreakUpDialog]. +class _BreakUpRow { + /// Creates an empty break-up row with fresh text controllers. + _BreakUpRow() + : titleController = TextEditingController(), + durationController = TextEditingController(); + + /// Controls the row's child task title field. + final TextEditingController titleController; + + /// Controls the row's optional duration-in-minutes field. + final TextEditingController durationController; + + /// Optional priority selected for this row. + PriorityLevel? priority; + + /// Reward level selected for this row. + RewardLevel reward = RewardLevel.notSet; + + /// Releases the text controllers owned by this row. + void dispose() { + titleController.dispose(); + durationController.dispose(); + } +} + +/// Dialog that collects one or more child task drafts to break up a parent +/// task, per MVP-AC-11 (row-level priority, reward, and duration). +/// +/// Shared by the task selection modal and timeline card quick actions so +/// both surfaces reuse the same break-up flow instead of duplicating it. +class BreakUpDialog extends StatefulWidget { + /// Creates a break-up dialog. + const BreakUpDialog({super.key}); + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State createState() => _BreakUpDialogState(); +} + +/// Private implementation type for `_BreakUpDialogState` in this library. +/// It owns the growable list of child-task rows shown by the dialog. +class _BreakUpDialogState extends State { + /// Editable rows currently shown in the dialog. + final List<_BreakUpRow> _rows = [_BreakUpRow()]; + + /// Releases resources owned by this object before it leaves the tree. + /// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks. + @override + void dispose() { + for (final row in _rows) { + row.dispose(); + } + super.dispose(); + } + + /// Whether at least one row has a non-empty title. + bool get _canSubmit => + _rows.any((row) => row.titleController.text.trim().isNotEmpty); + + /// Appends a new empty row. + void _addRow() { + setState(() => _rows.add(_BreakUpRow())); + } + + /// Removes the row at [index], keeping at least one row. + void _removeRow(int index) { + if (_rows.length <= 1) { + return; + } + setState(() { + _rows.removeAt(index).dispose(); + }); + } + + /// Builds child task drafts from rows with a non-empty title and closes + /// the dialog with the resulting list. + void _submit() { + final drafts = [ + for (final row in _rows) + if (row.titleController.text.trim().isNotEmpty) + ApplicationChildTaskDraft( + title: row.titleController.text.trim(), + priority: row.priority, + reward: row.reward, + durationMinutes: int.tryParse(row.durationController.text.trim()), + ), + ]; + Navigator.of(context).pop(drafts); + } + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: FocusFlowTokens.elevatedPanel, + title: const Text( + 'Break up task', + style: TextStyle(color: FocusFlowTokens.textPrimary), + ), + content: SizedBox( + width: 480, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < _rows.length; i++) + _BreakUpRowFields( + key: ValueKey('break-up-row-$i'), + rowIndex: i, + row: _rows[i], + onChanged: () => setState(() {}), + onRemove: _rows.length > 1 ? () => _removeRow(i) : null, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + key: const ValueKey('break-up-add-row'), + onPressed: _addRow, + icon: const Icon(Icons.add), + label: const Text('Add another task'), + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const ValueKey('break-up-confirm'), + onPressed: _canSubmit ? _submit : null, + child: const Text('Break up'), + ), + ], + ); + } +} + +/// Editable fields for a single [_BreakUpRow]. +class _BreakUpRowFields extends StatelessWidget { + /// Creates fields bound to [row] at [rowIndex]. + const _BreakUpRowFields({ + required this.rowIndex, + required this.row, + required this.onChanged, + this.onRemove, + super.key, + }); + + /// Position of this row, used to build stable field keys. + final int rowIndex; + + /// Row whose fields are rendered and mutated in place. + final _BreakUpRow row; + + /// Invoked after any field in this row changes. + final VoidCallback onChanged; + + /// Invoked when this row should be removed. Null when removal is disabled. + final VoidCallback? onRemove; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: TextField( + key: ValueKey('break-up-row-$rowIndex-title'), + controller: row.titleController, + decoration: const InputDecoration(labelText: 'Task title'), + onChanged: (_) => onChanged(), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 64, + child: TextField( + key: ValueKey('break-up-row-$rowIndex-duration'), + controller: row.durationController, + decoration: const InputDecoration(labelText: 'Min'), + keyboardType: TextInputType.number, + onChanged: (_) => onChanged(), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 132, + child: DropdownButtonFormField( + key: ValueKey('break-up-row-$rowIndex-priority'), + initialValue: row.priority, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Priority'), + items: [ + const DropdownMenuItem(value: null, child: Text('Unset')), + for (final level in PriorityLevel.values) + DropdownMenuItem(value: level, child: Text(level.name)), + ], + onChanged: (value) { + row.priority = value; + onChanged(); + }, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 132, + child: DropdownButtonFormField( + key: ValueKey('break-up-row-$rowIndex-reward'), + initialValue: row.reward, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Reward'), + items: [ + for (final level in RewardLevel.values) + DropdownMenuItem(value: level, child: Text(level.name)), + ], + onChanged: (value) { + if (value == null) { + return; + } + row.reward = value; + onChanged(); + }, + ), + ), + if (onRemove != null) + IconButton( + tooltip: 'Remove row', + onPressed: onRemove, + icon: const Icon(Icons.close), + ), + ], + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart b/apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart new file mode 100644 index 0000000..8948489 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Renders the shared Settings dialog for the FocusFlow Flutter UI. +library; + +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../theme/focus_flow_tokens.dart'; + +/// Time zone codes recognized by the app's runtime config loader. +const _timeZoneOptions = [ + 'UTC', + 'GMT', + 'PST', + 'PDT', + 'MST', + 'MDT', + 'CST', + 'CDT', + 'EST', + 'EDT', +]; + +/// Dialog that surfaces persisted [OwnerSettings] fields for editing. +/// +/// Returns an [OwnerSettingsUpdate] patch containing only the fields the user +/// changed when confirmed, or `null` when cancelled. +class SettingsDialog extends StatefulWidget { + /// Creates a settings dialog seeded with the currently persisted [initial] + /// settings. + const SettingsDialog({required this.initial, super.key}); + + /// Currently persisted owner settings shown as the dialog's starting + /// values. + final OwnerSettings initial; + + /// Creates the mutable state object used by this stateful widget. + /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. + @override + State createState() => _SettingsDialogState(); +} + +/// Private implementation type for `_SettingsDialogState` in this library. +class _SettingsDialogState extends State { + late String _timeZoneId; + late WallTime _dayStart; + late WallTime _dayEnd; + late bool _compactModeEnabled; + late final TextEditingController _freshDaysController; + late final TextEditingController _agingDaysController; + + /// Validation message for the day window, if any. + String? _dayWindowError; + + /// Validation message for the staleness thresholds, if any. + String? _stalenessError; + + /// Initializes state owned by this object before the first build. + /// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance. + @override + void initState() { + super.initState(); + final initial = widget.initial; + _timeZoneId = _timeZoneOptions.contains(initial.timeZoneId.toUpperCase()) + ? initial.timeZoneId.toUpperCase() + : _timeZoneOptions.first; + _dayStart = initial.dayStart; + _dayEnd = initial.dayEnd; + _compactModeEnabled = initial.compactModeEnabled; + _freshDaysController = TextEditingController( + text: initial.backlogStalenessSettings.greenMaxAge.inDays.toString(), + ); + _agingDaysController = TextEditingController( + text: initial.backlogStalenessSettings.blueMaxAge.inDays.toString(), + ); + } + + /// Releases resources owned by this object before it leaves the tree. + /// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks. + @override + void dispose() { + _freshDaysController.dispose(); + _agingDaysController.dispose(); + super.dispose(); + } + + Future _pickDayStart() async { + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: _dayStart.hour, minute: _dayStart.minute), + ); + if (picked == null) { + return; + } + setState(() { + _dayStart = WallTime(hour: picked.hour, minute: picked.minute); + _dayWindowError = null; + }); + } + + Future _pickDayEnd() async { + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay(hour: _dayEnd.hour, minute: _dayEnd.minute), + ); + if (picked == null) { + return; + } + setState(() { + _dayEnd = WallTime(hour: picked.hour, minute: picked.minute); + _dayWindowError = null; + }); + } + + void _submit() { + if (_dayStart.compareTo(_dayEnd) >= 0) { + setState(() => _dayWindowError = 'Day end must be after day start.'); + return; + } + final freshDays = int.tryParse(_freshDaysController.text.trim()); + final agingDays = int.tryParse(_agingDaysController.text.trim()); + if (freshDays == null || + agingDays == null || + freshDays < 0 || + agingDays < freshDays) { + setState( + () => _stalenessError = + 'Aging must be a number of days at least as large as Fresh.', + ); + return; + } + + Navigator.of(context).pop( + OwnerSettingsUpdate( + timeZoneId: _timeZoneId, + dayStart: _dayStart, + dayEnd: _dayEnd, + compactModeEnabled: _compactModeEnabled, + backlogStalenessSettings: BacklogStalenessSettings( + greenMaxAge: Duration(days: freshDays), + blueMaxAge: Duration(days: agingDays), + ), + ), + ); + } + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: FocusFlowTokens.elevatedPanel, + title: const Text( + 'Settings', + style: TextStyle(color: FocusFlowTokens.textPrimary), + ), + content: SizedBox( + width: 420, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Time & day', + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + key: const ValueKey('settings-timezone'), + initialValue: _timeZoneId, + decoration: const InputDecoration(labelText: 'Time zone'), + items: [ + for (final zone in _timeZoneOptions) + DropdownMenuItem(value: zone, child: Text(zone)), + ], + onChanged: (value) { + if (value == null) { + return; + } + setState(() => _timeZoneId = value); + }, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton( + key: const ValueKey('settings-day-start'), + onPressed: _pickDayStart, + child: Text('Day start: ${_dayStart.formatted}'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + key: const ValueKey('settings-day-end'), + onPressed: _pickDayEnd, + child: Text('Day end: ${_dayEnd.formatted}'), + ), + ), + ], + ), + if (_dayWindowError != null) ...[ + const SizedBox(height: 6), + Text( + _dayWindowError!, + style: const TextStyle(color: FocusFlowTokens.accentMagenta), + ), + ], + const SizedBox(height: 20), + const Text( + 'Compact mode', + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + SwitchListTile( + key: const ValueKey('settings-compact-mode'), + contentPadding: EdgeInsets.zero, + title: const Text( + 'Compact Today view', + style: TextStyle(color: FocusFlowTokens.textPrimary), + ), + subtitle: const Text( + 'Saved now; visual toggle is coming soon.', + style: TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 12, + ), + ), + value: _compactModeEnabled, + onChanged: (value) { + setState(() => _compactModeEnabled = value); + }, + ), + const SizedBox(height: 20), + const Text( + 'Backlog freshness', + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey('settings-fresh-days'), + controller: _freshDaysController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Fresh (days)', + ), + onChanged: (_) => setState(() => _stalenessError = null), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + key: const ValueKey('settings-aging-days'), + controller: _agingDaysController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Aging (days)', + ), + onChanged: (_) => setState(() => _stalenessError = null), + ), + ), + ], + ), + const SizedBox(height: 4), + const Text( + 'Anything older than Aging is shown as Stale.', + style: TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 12, + ), + ), + if (_stalenessError != null) ...[ + const SizedBox(height: 6), + Text( + _stalenessError!, + style: const TextStyle(color: FocusFlowTokens.accentMagenta), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const ValueKey('settings-save'), + onPressed: _submit, + child: const Text('Save'), + ), + ], + ); + } +} + +/// Formats a [WallTime] as `HH:MM`. +extension on WallTime { + String get formatted => + '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; +} diff --git a/apps/focus_flow_flutter/lib/widgets/sidebar.dart b/apps/focus_flow_flutter/lib/widgets/sidebar.dart index ab7a204..b9bdd27 100644 --- a/apps/focus_flow_flutter/lib/widgets/sidebar.dart +++ b/apps/focus_flow_flutter/lib/widgets/sidebar.dart @@ -6,12 +6,23 @@ library; import 'package:flutter/material.dart'; +import '../models/navigation/focus_flow_section.dart'; import '../theme/focus_flow_tokens.dart'; -/// Static sidebar for primary FocusFlow navigation. +/// Sidebar for primary FocusFlow navigation. class Sidebar extends StatelessWidget { /// Creates the FocusFlow sidebar. - const Sidebar({super.key}); + const Sidebar({ + required this.activeSection, + required this.onSectionSelected, + super.key, + }); + + /// Currently active app section. + final FocusFlowSection activeSection; + + /// Callback invoked when a section is selected. + final ValueChanged onSectionSelected; /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @@ -29,19 +40,44 @@ class Sidebar extends StatelessWidget { _NavItem( label: 'Today', icon: Icons.calendar_today, - active: true, - onTap: () {}, + section: FocusFlowSection.today, + active: activeSection == FocusFlowSection.today, + onTap: onSectionSelected, ), const SizedBox(height: 16), - _NavItem(label: 'Backlog', icon: Icons.layers, onTap: () {}), + _NavItem( + label: 'Backlog', + icon: Icons.layers, + section: FocusFlowSection.backlog, + active: activeSection == FocusFlowSection.backlog, + onTap: onSectionSelected, + ), const SizedBox(height: 16), - _NavItem(label: 'Projects', icon: Icons.work_outline, onTap: () {}), + _NavItem( + label: 'Projects', + icon: Icons.work_outline, + section: FocusFlowSection.projects, + active: activeSection == FocusFlowSection.projects, + onTap: onSectionSelected, + ), const SizedBox(height: 16), - _NavItem(label: 'Reports', icon: Icons.bar_chart, onTap: () {}), + _NavItem( + label: 'Reports', + icon: Icons.bar_chart, + section: FocusFlowSection.reports, + active: activeSection == FocusFlowSection.reports, + onTap: onSectionSelected, + ), const SizedBox(height: 30), const Divider(color: FocusFlowTokens.subtleBorder), const SizedBox(height: 18), - _NavItem(label: 'Settings', icon: Icons.settings, onTap: () {}), + _NavItem( + label: 'Settings', + icon: Icons.settings, + section: FocusFlowSection.settings, + active: activeSection == FocusFlowSection.settings, + onTap: onSectionSelected, + ), ], ), ), @@ -97,6 +133,7 @@ class _NavItem extends StatelessWidget { const _NavItem({ required this.label, required this.icon, + required this.section, required this.onTap, this.active = false, }); @@ -109,9 +146,13 @@ class _NavItem extends StatelessWidget { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final IconData icon; + /// Stores the `section` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final FocusFlowSection section; + /// Stores the `onTap` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. - final VoidCallback onTap; + final ValueChanged onTap; /// Stores the `active` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. @@ -127,9 +168,11 @@ class _NavItem extends StatelessWidget { return Material( color: Colors.transparent, child: InkWell( - key: active ? const ValueKey('nav-today-active') : null, + key: ValueKey( + active ? 'nav-${section.name}-active' : 'nav-${section.name}', + ), borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius), - onTap: onTap, + onTap: () => onTap(section), child: Container( height: 54, padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart index 18c29fc..12592a2 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -7,9 +7,11 @@ library; import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; import '../models/today_screen_models.dart'; import '../theme/focus_flow_tokens.dart'; +import 'dialogs/break_up_dialog.dart'; import 'status_circle_button.dart'; import 'timeline/difficulty_bars.dart'; import 'timeline/reward_icon.dart'; @@ -31,6 +33,7 @@ class TaskSelectionModal extends StatelessWidget { this.onPushToTomorrow, this.onPushToBacklog, this.onRemove, + this.onBreakUp, super.key, }); @@ -55,6 +58,26 @@ class TaskSelectionModal extends StatelessWidget { /// Callback invoked when Remove is selected. final Future Function(TimelineCardModel card)? onRemove; + /// Callback invoked with drafted child tasks after Break up is confirmed. + final Future Function( + TimelineCardModel card, + List children, + )? + onBreakUp; + + /// Opens the break-up dialog and forwards drafted child tasks to + /// [onBreakUp] when the user confirms with at least one task. + Future _handleBreakUp(BuildContext context) async { + final drafts = await showDialog>( + context: context, + builder: (_) => const BreakUpDialog(), + ); + if (drafts == null || drafts.isEmpty) { + return; + } + await onBreakUp!(card, drafts); + } + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -132,8 +155,21 @@ class TaskSelectionModal extends StatelessWidget { _ActionButton( icon: Icons.inventory_2_outlined, label: 'Backlog', + onTap: onPushToBacklog == null + ? null + : () { + unawaited(onPushToBacklog!(card)); + }, + ), + _ActionButton( + icon: Icons.apps, + label: 'Break up', + onTap: onBreakUp == null + ? null + : () { + unawaited(_handleBreakUp(context)); + }, ), - const _ActionButton(icon: Icons.apps, label: 'Break up'), _ActionButton( icon: Icons.delete_outline, label: 'Remove', diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart b/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart index 3580a23..86262d5 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart @@ -19,6 +19,21 @@ class DifficultyBars extends StatelessWidget { super.key, }); + /// Creates difficulty bars directly from a domain difficulty level. + factory DifficultyBars.forLevel({ + required DifficultyLevel difficulty, + Color color = FocusFlowTokens.restPurple, + Size size = const Size(54, 34), + Key? key, + }) { + return DifficultyBars( + key: key, + difficulty: tokenForLevel(difficulty), + color: color, + size: size, + ); + } + /// Difficulty token that determines how many bars are filled. final TimelineDifficultyIconToken difficulty; @@ -52,6 +67,18 @@ class DifficultyBars extends StatelessWidget { }; } + /// Converts a domain difficulty level to a timeline difficulty token. + static TimelineDifficultyIconToken tokenForLevel(DifficultyLevel difficulty) { + return switch (difficulty) { + DifficultyLevel.notSet => TimelineDifficultyIconToken.notSet, + DifficultyLevel.veryEasy => TimelineDifficultyIconToken.veryEasy, + DifficultyLevel.easy => TimelineDifficultyIconToken.easy, + DifficultyLevel.medium => TimelineDifficultyIconToken.medium, + DifficultyLevel.hard => TimelineDifficultyIconToken.hard, + DifficultyLevel.veryHard => TimelineDifficultyIconToken.veryHard, + }; + } + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart index 9615d88..af82fe0 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart @@ -114,7 +114,7 @@ class _CardMetrics { /// Returns the derived `actionsWidth` value for this object. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. - double get actionsWidth => actionButtonSize * 3 + actionGap; + double get actionsWidth => actionButtonSize * 4 + actionGap; /// Returns the derived `pushButtonHeight` value for this object. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart index 4be5113..8dd5b10 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart @@ -124,3 +124,55 @@ class _QuickPushIcon extends StatelessWidget { ); } } + +/// Private implementation type for `_QuickBreakUpIcon` in this library. +/// It exposes the same break-up dialog flow as the task selection modal from +/// the timeline card's compact hover actions. +class _QuickBreakUpIcon extends StatelessWidget { + /// Creates a `_QuickBreakUpIcon` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _QuickBreakUpIcon({ + required this.card, + required this.color, + required this.metrics, + this.onBreakUp, + }); + + /// Stores the `card` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final TimelineCardModel card; + + /// Stores the `color` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Color color; + + /// Stores the `metrics` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final _CardMetrics metrics; + + /// Callback invoked when Break up is selected. + final Future Function()? onBreakUp; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final enabled = onBreakUp != null; + return Tooltip( + message: 'Break up', + child: Semantics( + button: true, + enabled: enabled, + label: 'Break up ${card.title}', + child: GestureDetector( + onTap: enabled ? () => unawaited(onBreakUp!()) : null, + behavior: HitTestBehavior.opaque, + child: SizedBox.square( + dimension: metrics.actionButtonSize, + child: Icon(Icons.apps, color: color, size: metrics.actionIconSize), + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart index 059f05b..713537c 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart @@ -18,6 +18,7 @@ class _QuickActions extends StatelessWidget { this.onPushToNext, this.onPushToTomorrow, this.onPushToBacklog, + this.onBreakUp, }); /// Stores the `card` value for this object. @@ -53,6 +54,9 @@ class _QuickActions extends StatelessWidget { /// Callback invoked when Push to backlog is selected. final Future Function()? onPushToBacklog; + /// Callback invoked when Break up is selected. + final Future Function()? onBreakUp; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -90,6 +94,20 @@ class _QuickActions extends StatelessWidget { onPushToTomorrow: onPushToTomorrow, onPushToBacklog: onPushToBacklog, ), + if (onBreakUp != null) + _QuickBreakUpIcon( + card: card, + color: color, + metrics: metrics, + onBreakUp: onBreakUp, + ) + else + _NoOpIcon( + icon: Icons.apps, + tooltip: 'Break up', + color: color, + metrics: metrics, + ), _NoOpIcon( icon: Icons.calendar_today, tooltip: 'Schedule', diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart index 29eb8ed..f9f8e07 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart @@ -62,6 +62,9 @@ class _TaskTimelineCardState extends State { onPushToBacklog: widget.onPushToBacklog == null ? null : () => _runPush(widget.onPushToBacklog!), + onBreakUp: widget.onBreakUpCard == null + ? null + : () => _handleBreakUp(context), ), ), ], @@ -111,6 +114,25 @@ class _TaskTimelineCardState extends State { ); } + /// Opens the break-up dialog and forwards drafted child tasks to + /// [TaskTimelineCard.onBreakUpCard] when the user confirms with at least + /// one task. + Future _handleBreakUp(BuildContext context) async { + final drafts = await showDialog>( + context: context, + builder: (_) => const BreakUpDialog(), + ); + if (drafts == null || drafts.isEmpty) { + return; + } + logger.debug( + () => + 'Card break-up requested. cardId=${widget.card.id} ' + 'childCount=${drafts.length}', + ); + await widget.onBreakUpCard!(widget.card, drafts); + } + /// Runs the `_runPush` operation and completes after its asynchronous work finishes. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. Future _runPush( diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart index ba78fd4..d28cfdc 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart @@ -17,6 +17,7 @@ class _TrailingControls extends StatelessWidget { this.onPushToNext, this.onPushToTomorrow, this.onPushToBacklog, + this.onBreakUp, }); /// Stores the `card` value for this object. @@ -48,6 +49,9 @@ class _TrailingControls extends StatelessWidget { /// Callback invoked when Push to backlog is selected. final Future Function()? onPushToBacklog; + /// Callback invoked when Break up is selected. + final Future Function()? onBreakUp; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -66,6 +70,7 @@ class _TrailingControls extends StatelessWidget { onPushToNext: onPushToNext, onPushToTomorrow: onPushToTomorrow, onPushToBacklog: onPushToBacklog, + onBreakUp: onBreakUp, ), if (card.showPastTaskPushButton) ...[ _PastTaskPushButton( diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart index 937d1d2..1717dd6 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart @@ -8,10 +8,12 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:scheduler_core/scheduler_core.dart' show logger; +import 'package:scheduler_core/scheduler_core.dart' + show ApplicationChildTaskDraft, logger; import '../../models/today_screen_models.dart'; import '../../theme/focus_flow_tokens.dart'; +import '../dialogs/break_up_dialog.dart'; import '../status_circle_button.dart'; import 'difficulty_bars.dart'; import 'reward_icon.dart'; @@ -37,6 +39,7 @@ class TaskTimelineCard extends StatefulWidget { this.onPushToNext, this.onPushToTomorrow, this.onPushToBacklog, + this.onBreakUpCard, super.key, }); @@ -58,6 +61,13 @@ class TaskTimelineCard extends StatefulWidget { /// Callback invoked when the card requests Push to backlog. final Future Function(TimelineCardModel card)? onPushToBacklog; + /// Callback invoked with drafted child tasks after Break up is confirmed. + final Future Function( + TimelineCardModel card, + List children, + )? + onBreakUpCard; + /// Creates the mutable state object used by this stateful widget. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. @override diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart index d9d3fb3..094cf15 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -6,6 +6,8 @@ library; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart' + show ApplicationChildTaskDraft; import '../../models/today_screen_models.dart'; import '../../theme/focus_flow_tokens.dart'; @@ -29,6 +31,7 @@ class TimelineView extends StatefulWidget { this.onPushToNext, this.onPushToTomorrow, this.onPushToBacklog, + this.onBreakUpCard, this.now, this.scrollTargetCardId, this.scrollRequest = 0, @@ -60,6 +63,14 @@ class TimelineView extends StatefulWidget { /// Callback invoked when a task card requests Push to backlog. final Future Function(TimelineCardModel card)? onPushToBacklog; + /// Callback invoked with drafted child tasks after a task card's Break up + /// action is confirmed. + final Future Function( + TimelineCardModel card, + List children, + )? + onBreakUpCard; + /// Clock used to center the initial scroll position. final DateTime Function()? now; diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart index b850032..3fa792f 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart @@ -148,6 +148,7 @@ class _TimelineViewState extends State { onPushToNext: widget.onPushToNext, onPushToTomorrow: widget.onPushToTomorrow, onPushToBacklog: widget.onPushToBacklog, + onBreakUpCard: widget.onBreakUpCard, ), ), ), diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index e5f3acd..2a18ce5 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -22,6 +22,7 @@ class TopBar extends StatelessWidget { this.onNextDate, this.onDatePressed, this.onDateLabelPressed, + this.onOpenSettings, super.key, }); @@ -40,6 +41,9 @@ class TopBar extends StatelessWidget { /// Callback invoked when the displayed date label is activated. final VoidCallback? onDateLabelPressed; + /// Callback invoked when the Settings button is activated. + final VoidCallback? onOpenSettings; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -130,7 +134,7 @@ class TopBar extends StatelessWidget { const Spacer(), _ModeToggle(metrics: metrics), SizedBox(width: metrics.settingsGap), - _SettingsButton(metrics: metrics), + _SettingsButton(metrics: metrics, onPressed: onOpenSettings), ], ), ); diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart index c356dcb..4b42f04 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart @@ -8,18 +8,22 @@ part of '../top_bar.dart'; class _SettingsButton extends StatelessWidget { /// Creates a `_SettingsButton` instance with the values required by its invariants. /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. - const _SettingsButton({required this.metrics}); + const _SettingsButton({required this.metrics, this.onPressed}); /// Stores the `metrics` value for this object. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. final _TopBarMetrics metrics; + /// Callback invoked when the button is activated. + final VoidCallback? onPressed; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override Widget build(BuildContext context) { return OutlinedButton( - onPressed: () {}, + key: const ValueKey('top-bar-settings-button'), + onPressed: onPressed, style: OutlinedButton.styleFrom( foregroundColor: FocusFlowTokens.textPrimary, side: BorderSide( diff --git a/apps/focus_flow_flutter/pubspec.lock b/apps/focus_flow_flutter/pubspec.lock index 593d5be..0f4f51d 100644 --- a/apps/focus_flow_flutter/pubspec.lock +++ b/apps/focus_flow_flutter/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: drift - sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c" + sha256: c21b9af62e78f86837638dda6c33506bd9444ca8a32cad2b99c07369cf9e2462 url: "https://pub.dev" source: hosted - version: "2.34.0" + version: "2.34.1" fake_async: dependency: transitive description: diff --git a/apps/focus_flow_flutter/test/app/demo_backlog_seed_test.dart b/apps/focus_flow_flutter/test/app/demo_backlog_seed_test.dart new file mode 100644 index 0000000..fe57096 --- /dev/null +++ b/apps/focus_flow_flutter/test/app/demo_backlog_seed_test.dart @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests Demo Backlog Seed behavior in the FocusFlow Flutter app. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart'; + +/// Runs demo Backlog seed tests. +void main() { + test('seeded backlog board exposes mockup-scale domain data', () async { + final composition = DemoSchedulerComposition.seeded( + selectedDate: CivilDate(2025, 5, 20), + ); + addTearDown(composition.dispose); + + final board = (await composition.managementUseCases.getBacklogBoard( + GetBacklogBoardRequest( + context: composition.context('demo-backlog-board'), + localDate: composition.date, + sortKey: BacklogSortKey.age, + ), + )).requireValue; + final items = board.columns.expand((column) => column.items).toList(); + + expect(board.summary.totalTaskCount, 24); + expect(board.summary.totalEstimatedMinutes, 735); + expect(_distributionCounts(board.summary.projectDistribution), { + 'Home': 5, + 'Learning': 4, + 'Personal': 5, + 'Side Project': 2, + 'Work': 8, + }); + expect(_distributionCounts(board.summary.priorityDistribution), { + 'High': 7, + 'Medium': 9, + 'Low': 8, + }); + expect(items.map((item) => item.title), containsAll(_visibleSeedTitles)); + + final review = items.singleWhere( + (item) => item.title == 'Review Q3 budget', + ); + expect(review.projectName, 'Work'); + expect(review.durationMinutes, 60); + expect(review.priority, PriorityLevel.high); + expect(review.reward, RewardLevel.medium); + expect(review.difficulty, DifficultyLevel.easy); + }); + + test('seeded review detail stays within deferred metadata scope', () async { + final composition = DemoSchedulerComposition.seeded( + selectedDate: CivilDate(2025, 5, 20), + ); + addTearDown(composition.dispose); + + final detail = (await composition.managementUseCases.getBacklogTaskDetail( + GetBacklogTaskDetailRequest( + context: composition.context('demo-review-detail'), + localDate: composition.date, + taskId: 'review-q3-budget', + ), + )).requireValue; + + expect(detail.item.title, 'Review Q3 budget'); + expect(detail.notes, isNull); + expect(detail.tags, isEmpty); + expect(detail.suggestedSlots, isNotEmpty); + }); +} + +/// Visible task titles from the Backlog board mockup that must be present in the seed. +const _visibleSeedTitles = [ + 'Reply to 3 emails', + 'Update project status', + 'Schedule dentist appt', + 'Clean up downloads folder', + 'Plan tomorrow', + 'Review Q3 budget', + 'Plan next week', + 'Define weekly goals', + 'Time block schedule', + 'Organize kitchen drawers', + 'Build analytics dashboard', + 'Write marketing strategy', + 'Complete online course', + 'Module 1: Foundations', + 'Module 2: Deep Dive', + 'Module 3: Practice', + 'Learn guitar', + 'Start YouTube channel', + 'Travel to Japan', + 'Home office upgrade', + 'Read 2 books', +]; + +/// Top-level helper that performs the `_distributionCounts` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +Map _distributionCounts( + BacklogDistributionReadModel distribution, +) { + return {for (final entry in distribution.entries) entry.label: entry.count}; +} diff --git a/apps/focus_flow_flutter/test/app/settings_test.dart b/apps/focus_flow_flutter/test/app/settings_test.dart new file mode 100644 index 0000000..88406ea --- /dev/null +++ b/apps/focus_flow_flutter/test/app/settings_test.dart @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests app-level settings dialog behavior. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart'; +import 'package:focus_flow_flutter/app/focus_flow_app.dart'; + +/// Runs app-level settings dialog tests. +void main() { + testWidgets('sidebar Settings opens a dialog seeded with persisted values', ( + tester, + ) async { + await _pumpPlanApp(tester); + + await tester.tap(find.byKey(const ValueKey('nav-settings'))); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('UTC'), findsOneWidget); + + final compactSwitch = tester.widget( + find.byKey(const ValueKey('settings-compact-mode')), + ); + expect(compactSwitch.value, isTrue); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + }); + + testWidgets('top bar Settings button opens the same dialog', (tester) async { + await _pumpPlanApp(tester); + + await tester.tap(find.byKey(const ValueKey('top-bar-settings-button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('settings-timezone')), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + + testWidgets('saving settings persists the change and reopens with it', ( + tester, + ) async { + await _pumpPlanApp(tester); + + await tester.tap(find.byKey(const ValueKey('nav-settings'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('settings-timezone'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('EST').last); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('settings-save'))); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsNothing); + + await tester.tap(find.byKey(const ValueKey('nav-settings'))); + await tester.pumpAndSettle(); + + expect(find.text('EST'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + + testWidgets('invalid staleness thresholds show a calm inline error', ( + tester, + ) async { + await _pumpPlanApp(tester); + + await tester.tap(find.byKey(const ValueKey('nav-settings'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('settings-aging-days')), + '1', + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('settings-save'))); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + expect( + find.text('Aging must be a number of days at least as large as Fresh.'), + findsOneWidget, + ); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); +} + +/// Pumps the FocusFlow app at the desktop test size. +Future _pumpPlanApp(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1586, 992)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(FocusFlowApp(composition: _composition())); + await tester.pumpAndSettle(); +} + +/// Builds the seeded demo composition used by settings dialog tests. +DemoSchedulerComposition _composition() { + return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20)); +} diff --git a/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart b/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart new file mode 100644 index 0000000..6e683f4 --- /dev/null +++ b/apps/focus_flow_flutter/test/controllers/backlog_board_controller_test.dart @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests Backlog board controller behavior. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:focus_flow_flutter/controllers/backlog_board_controller.dart'; +import 'package:focus_flow_flutter/controllers/selected_date_controller.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +/// Runs Backlog board controller tests. +void main() { + group('BacklogBoardController', () { + test('load reads selected date and exposes ready data', () async { + BacklogBoardReadRequest? capturedRequest; + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + readBoard: (request) async { + capturedRequest = request; + return ApplicationResult.success(_board(taskCount: 1)); + }, + readTaskDetail: _detailReader, + ); + addTearDown(controller.dispose); + + await controller.load(); + + expect(capturedRequest!.localDate, CivilDate(2026, 7, 7)); + expect(capturedRequest!.sortKey, BacklogSortKey.custom); + expect(capturedRequest!.boardFilters.isEmpty, isTrue); + final state = controller.state; + expect(state, isA()); + expect((state as BacklogBoardReady).data.summary.totalTaskCount, 1); + }); + + test('load maps empty and failure states', () async { + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + readBoard: (_) async { + return ApplicationResult.success(_board(taskCount: 0)); + }, + readTaskDetail: _detailReader, + ); + addTearDown(controller.dispose); + + await controller.load(); + expect(controller.state, isA()); + + final failing = BacklogBoardController( + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + readBoard: (_) async { + return ApplicationResult.failure( + const ApplicationFailure( + code: ApplicationFailureCode.validation, + detailCode: 'readFailed', + ), + ); + }, + readTaskDetail: _detailReader, + ); + addTearDown(failing.dispose); + + await failing.load(); + final state = failing.state; + expect(state, isA()); + expect((state as BacklogBoardFailure).message, 'readFailed'); + }); + + test('selection loads detail without persisting selection', () async { + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + var detailReads = 0; + final controller = BacklogBoardController( + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + readBoard: (_) async { + return ApplicationResult.success(_board(taskCount: 1)); + }, + readTaskDetail: (taskId, localDate) async { + detailReads += 1; + expect(taskId, 'task-1'); + expect(localDate, CivilDate(2026, 7, 7)); + return ApplicationResult.success(_detail()); + }, + ); + addTearDown(controller.dispose); + + await controller.load(); + await controller.selectTask('task-1'); + + final state = controller.state as BacklogBoardReady; + expect(detailReads, 1); + expect(state.data.selectedTaskId, 'task-1'); + + controller.clearSelection(); + + final cleared = controller.state as BacklogBoardReady; + expect(cleared.data.selectedTaskId, isNull); + }); + + test('detail failures stay local to the selected drawer', () async { + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + readBoard: (_) async { + return ApplicationResult.success(_board(taskCount: 1)); + }, + readTaskDetail: (taskId, localDate) async { + return ApplicationResult.failure( + const ApplicationFailure( + code: ApplicationFailureCode.notFound, + detailCode: 'taskNotFound', + ), + ); + }, + ); + addTearDown(controller.dispose); + + await controller.load(); + await controller.selectTask('task-1'); + + expect(controller.state, isA()); + expect(controller.selectedTaskId, 'task-1'); + expect(controller.selectedTaskDetail, isNull); + expect(controller.isSelectedTaskDetailLoading, isFalse); + expect(controller.selectedTaskDetailError, 'taskNotFound'); + }); + }); +} + +/// Test detail reader that returns a stable detail model. +Future> _detailReader( + String taskId, + CivilDate localDate, +) async { + return ApplicationResult.success(_detail()); +} + +/// Builds a Backlog board query result with [taskCount]. +BacklogBoardQueryResult _board({required int taskCount}) { + final items = [ + for (var index = 0; index < taskCount; index += 1) + _item(id: 'task-${index + 1}'), + ]; + final totalEstimatedMinutes = items.fold( + 0, + (sum, item) => sum + (item.durationMinutes ?? 0), + ); + return BacklogBoardQueryResult( + ownerId: 'owner-1', + localDate: CivilDate(2026, 7, 7), + columns: [ + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.doNext, + title: 'Do Next', + subtitle: 'Ready.', + accentToken: 'green', + items: items, + ), + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.needTimeBlock, + title: 'Need Time Block', + subtitle: 'Block.', + accentToken: 'yellow', + items: const [], + ), + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.breakUpFirst, + title: 'Break Up First', + subtitle: 'Split.', + accentToken: 'purple', + items: const [], + ), + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.notNow, + title: 'Not Now', + subtitle: 'Later.', + accentToken: 'blue', + items: const [], + ), + ], + summary: BacklogBoardSummaryReadModel( + totalTaskCount: taskCount, + totalEstimatedMinutes: totalEstimatedMinutes, + priorityDistribution: BacklogDistributionReadModel( + title: 'Priority', + entries: const [], + ), + projectDistribution: BacklogDistributionReadModel( + title: 'Project', + entries: const [], + ), + durationDistribution: BacklogDistributionReadModel( + title: 'Duration', + entries: const [], + ), + planningTip: 'Pick one task.', + ), + projectOptions: const [], + ); +} + +/// Builds a Backlog board item for tests. +BacklogBoardItemReadModel _item({required String id}) { + return BacklogBoardItemReadModel( + taskId: id, + title: 'Task $id', + projectId: 'home', + projectName: 'Home', + projectColorToken: 'project-home', + durationMinutes: 30, + priority: PriorityLevel.medium, + reward: RewardLevel.low, + difficulty: DifficultyLevel.easy, + bucket: BacklogBoardBucket.doNext, + bucketReason: 'Ready.', + createdAt: DateTime.utc(2026, 7, 7), + updatedAt: DateTime.utc(2026, 7, 7), + childPreview: const [], + tags: const [], + ); +} + +/// Builds a Backlog task detail model for tests. +BacklogTaskDetailReadModel _detail() { + return BacklogTaskDetailReadModel( + item: _item(id: 'task-1'), + addedLabel: 'Added today', + tags: const [], + projectOptions: const [], + suggestedSlots: const [], + ); +} diff --git a/apps/focus_flow_flutter/test/persistent_composition_test.dart b/apps/focus_flow_flutter/test/persistent_composition_test.dart index 1d157a6..17d0a8b 100644 --- a/apps/focus_flow_flutter/test/persistent_composition_test.dart +++ b/apps/focus_flow_flutter/test/persistent_composition_test.dart @@ -552,6 +552,7 @@ SchedulerCommandController _commandController( }) { return SchedulerCommandController( commands: composition.commandUseCases, + management: composition.managementUseCases, contextFor: composition.context, selectedDate: () => selectedDate, refreshReads: () async {}, diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 3f53e5d..2372e69 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -41,6 +41,34 @@ void main() { expect(find.byKey(const ValueKey('nav-today-active')), findsOneWidget); }); + testWidgets('sidebar switches to Backlog shell', (tester) async { + await _pumpPlanApp(tester); + + await tester.tap(find.text('Backlog').first); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('nav-backlog-active')), findsOneWidget); + expect(find.byKey(const ValueKey('nav-today-active')), findsNothing); + expect( + find.text('Choose work that fits your current energy.'), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('backlog-search-field')), findsOneWidget); + expect(find.text('Search backlog...'), findsOneWidget); + expect(find.text('Filters'), findsOneWidget); + expect(find.text('Sort: Custom'), findsOneWidget); + expect(find.text('Group: None'), findsOneWidget); + expect(find.text('Board'), findsOneWidget); + expect(find.text('New Task'), findsOneWidget); + expect(find.text('Backlog summary'), findsOneWidget); + expect(find.text('24 tasks'), findsOneWidget); + expect(find.text('Do Next'), findsOneWidget); + expect(find.text('Need Time Block'), findsOneWidget); + expect(find.text('Break Up First'), findsOneWidget); + expect(find.text('Not Now'), findsOneWidget); + expect(find.text('Review Q3 budget'), findsOneWidget); + }); + testWidgets('seeded Today renders backend-backed compact mockup data', ( tester, ) async { @@ -96,51 +124,64 @@ void main() { expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5); }); - testWidgets('task modal opens closes and keeps compact actions inert', ( - tester, - ) async { - await _pumpPlanApp(tester); + testWidgets( + 'task modal opens and closes, and Break up opens a cancellable dialog', + (tester) async { + await _pumpPlanApp(tester); - expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); - await _scrollIntoView(tester, const ValueKey('pay-bill')); - await tester.tap(find.byKey(const ValueKey('pay-bill'))); - await tester.pumpAndSettle(); + await _scrollIntoView(tester, const ValueKey('pay-bill')); + await tester.tap(find.byKey(const ValueKey('pay-bill'))); + await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); - expect(find.text('Pay bill'), findsNWidgets(2)); - expect(find.textContaining('Required'), findsWidgets); - expect(find.byTooltip('Reward level 2'), findsOneWidget); - expect(find.byTooltip('Medium effort'), findsOneWidget); - expect(find.byTooltip('Mark complete'), findsNothing); - expect(find.text('Reward level 2'), findsNothing); - expect(find.text('Medium effort'), findsNothing); - expect(find.text('Done'), findsNothing); - expect(find.text('Push'), findsWidgets); - expect(find.text('Move to Backlog'), findsNothing); - expect(find.text('Backlog'), findsWidgets); - expect(find.text('Break up'), findsOneWidget); + expect( + find.byKey(const ValueKey('task-selection-modal')), + findsOneWidget, + ); + expect(find.text('Pay bill'), findsNWidgets(2)); + expect(find.textContaining('Required'), findsWidgets); + expect(find.byTooltip('Reward level 2'), findsOneWidget); + expect(find.byTooltip('Medium effort'), findsOneWidget); + expect(find.byTooltip('Mark complete'), findsNothing); + expect(find.text('Reward level 2'), findsNothing); + expect(find.text('Medium effort'), findsNothing); + expect(find.text('Done'), findsNothing); + expect(find.text('Push'), findsWidgets); + expect(find.text('Move to Backlog'), findsNothing); + expect(find.text('Backlog'), findsWidgets); + expect(find.text('Break up'), findsOneWidget); - await tester.tap(find.text('Break up')); - await tester.pumpAndSettle(); + await tester.tap(find.text('Break up')); + await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); - expect(find.text('Pay bill'), findsNWidgets(2)); + expect(find.text('Break up task'), findsOneWidget); + expect(find.byKey(const ValueKey('break-up-confirm')), findsOneWidget); - await tester.tap(find.byKey(const ValueKey('close-task-modal'))); - await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect( + find.byKey(const ValueKey('task-selection-modal')), + findsOneWidget, + ); + expect(find.text('Pay bill'), findsNWidgets(2)); - await _scrollIntoView(tester, const ValueKey('pay-bill')); - await tester.tap(find.byKey(const ValueKey('pay-bill'))); - await tester.pumpAndSettle(); - await tester.tapAt(const Offset(1450, 140)); - await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('close-task-modal'))); + await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); - expect(find.text('Pay bill'), findsOneWidget); - }); + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + + await _scrollIntoView(tester, const ValueKey('pay-bill')); + await tester.tap(find.byKey(const ValueKey('pay-bill'))); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(1450, 140)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect(find.text('Pay bill'), findsOneWidget); + }, + ); testWidgets('task modal Push exposes destination labels and callbacks', ( tester, @@ -870,6 +911,51 @@ void main() { await gesture.removePointer(); }); + testWidgets( + 'hover Break up icon opens the same break-up dialog as the task modal', + (tester) async { + TimelineCardModel? brokenUpCard; + List? brokenUpChildren; + + await _pumpTimelineCard( + tester, + card: _timelineCard(id: 'quick-break-up', title: 'Current task'), + size: const Size(420, 88), + onBreakUpCard: (card, children) async { + brokenUpCard = card; + brokenUpChildren = children; + }, + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('quick-break-up')), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.apps)); + await tester.pumpAndSettle(); + + expect(find.text('Break up task'), findsOneWidget); + + await tester.enterText( + find.byKey(const ValueKey('break-up-row-0-title')), + 'Child task', + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('break-up-confirm'))); + await tester.pumpAndSettle(); + + expect(brokenUpCard?.id, 'quick-break-up'); + expect(brokenUpChildren, hasLength(1)); + expect(brokenUpChildren!.single.title, 'Child task'); + + await gesture.removePointer(); + }, + ); + testWidgets('Push menu dismisses on outside click without callback', ( tester, ) async { @@ -1223,6 +1309,11 @@ Future _pumpTimelineCard( Future Function(TimelineCardModel card)? onPushToNext, Future Function(TimelineCardModel card)? onPushToTomorrow, Future Function(TimelineCardModel card)? onPushToBacklog, + Future Function( + TimelineCardModel card, + List children, + )? + onBreakUpCard, }) async { await tester.binding.setSurfaceSize( Size(size.width + 120, size.height + 220), @@ -1242,6 +1333,7 @@ Future _pumpTimelineCard( onPushToNext: onPushToNext, onPushToTomorrow: onPushToTomorrow, onPushToBacklog: onPushToBacklog, + onBreakUpCard: onBreakUpCard, ), ), ), diff --git a/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart b/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart new file mode 100644 index 0000000..86f018e --- /dev/null +++ b/apps/focus_flow_flutter/test/widgets/backlog_board_screen_test.dart @@ -0,0 +1,999 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests Backlog board screen card rendering. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'package:focus_flow_flutter/controllers/backlog_board_controller.dart'; +import 'package:focus_flow_flutter/controllers/scheduler_command_controller.dart'; +import 'package:focus_flow_flutter/controllers/selected_date_controller.dart'; +import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; +import 'package:focus_flow_flutter/theme/focus_flow_tokens.dart'; +import 'package:focus_flow_flutter/widgets/backlog/backlog_board_screen.dart'; + +/// Runs Backlog board widget tests. +void main() { + testWidgets('renders card metadata child previews and selected state', ( + tester, + ) async { + final detailReads = []; + final item = _boardItemWithChildren(); + final board = _boardWithItem(item); + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + readBoard: (_) async => ApplicationResult.success(board), + readTaskDetail: (taskId, localDate) async { + detailReads.add(taskId); + return ApplicationResult.success(_detailFor(item)); + }, + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + ); + addTearDown(controller.dispose); + addTearDown(selectedDates.dispose); + + await controller.load(); + await _pumpBacklogScreen(tester, controller); + + expect(find.text('Do Next'), findsOneWidget); + expect(find.text('Need Time Block'), findsOneWidget); + expect(find.text('Break Up First'), findsOneWidget); + expect(find.text('Not Now'), findsOneWidget); + final card = find.byKey(const ValueKey('backlog-card-plan-week')); + expect(card, findsOneWidget); + expect(find.text('Plan next week'), findsOneWidget); + expect(find.text('Outline priorities + tasks'), findsOneWidget); + expect( + find.descendant(of: card, matching: find.text('30 min')), + findsOneWidget, + ); + expect( + find.descendant(of: card, matching: find.text('Work')), + findsOneWidget, + ); + expect( + find.descendant(of: card, matching: find.text('High')), + findsOneWidget, + ); + expect( + find.descendant(of: card, matching: find.text('Difficulty')), + findsOneWidget, + ); + expect( + find.descendant(of: card, matching: find.text('Reward')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('backlog-child-define-weekly-goals')), + findsOneWidget, + ); + expect(find.text('Define weekly goals'), findsOneWidget); + expect(find.text('15 min'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week'))); + await tester.pumpAndSettle(); + + expect(detailReads, ['plan-week']); + expect(controller.selectedTaskId, 'plan-week'); + final cardContainer = tester.widget( + find.byKey(const ValueKey('backlog-card-container-plan-week')), + ); + final decoration = cardContainer.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.top.color, FocusFlowTokens.accentMagenta); + }); + + testWidgets('drawer overlays board and close clears selected state', ( + tester, + ) async { + final item = _boardItemWithChildren(); + final board = _boardWithItem(item); + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + readBoard: (_) async => ApplicationResult.success(board), + readTaskDetail: (taskId, localDate) async { + return ApplicationResult.success(_detailFor(item)); + }, + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + ); + addTearDown(controller.dispose); + addTearDown(selectedDates.dispose); + + await controller.load(); + await _pumpBacklogScreen(tester, controller); + + final cardFinder = find.byKey(const ValueKey('backlog-card-plan-week')); + final beforeOpen = tester.getTopLeft(cardFinder); + + await tester.tap(cardFinder); + await tester.pumpAndSettle(); + + final drawer = find.byKey(const ValueKey('backlog-task-drawer')); + expect(drawer, findsOneWidget); + expect( + find.descendant(of: drawer, matching: find.text('Plan next week')), + findsOneWidget, + ); + expect(tester.getTopLeft(cardFinder), beforeOpen); + expect(controller.selectedTaskId, 'plan-week'); + + await tester.tap(find.byKey(const ValueKey('backlog-drawer-close-button'))); + await tester.pumpAndSettle(); + + expect(drawer, findsNothing); + expect(controller.selectedTaskId, isNull); + final cardContainer = tester.widget( + find.byKey(const ValueKey('backlog-card-container-plan-week')), + ); + final decoration = cardContainer.decoration! as BoxDecoration; + final border = decoration.border! as Border; + expect(border.top.color, isNot(FocusFlowTokens.accentMagenta)); + }); + + testWidgets('detail read failure stays inside drawer overlay', ( + tester, + ) async { + final item = _boardItemWithChildren(); + final board = _boardWithItem(item); + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + readBoard: (_) async => ApplicationResult.success(board), + readTaskDetail: (taskId, localDate) async { + return ApplicationResult.failure( + const ApplicationFailure( + code: ApplicationFailureCode.notFound, + detailCode: 'taskNotFound', + ), + ); + }, + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + ); + addTearDown(controller.dispose); + addTearDown(selectedDates.dispose); + + await controller.load(); + await _pumpBacklogScreen(tester, controller); + + await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week'))); + await tester.pumpAndSettle(); + + final drawer = find.byKey(const ValueKey('backlog-task-drawer')); + expect(drawer, findsOneWidget); + expect(find.text('Could not load task details'), findsOneWidget); + expect(find.text('taskNotFound'), findsOneWidget); + expect(find.text('Could not load backlog'), findsNothing); + expect(find.text('Plan next week'), findsOneWidget); + expect(controller.selectedTaskId, 'plan-week'); + }); + + testWidgets('new task creates backlog item through command controller', ( + tester, + ) async { + final harness = _BacklogCommandHarness.empty(); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-new-task-button'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('backlog-new-task-title-field')), + 'Draft outline', + ); + await tester.tap( + find.byKey(const ValueKey('backlog-new-task-create-button')), + ); + await tester.pumpAndSettle(); + + expect(harness.commandController.state, isA()); + expect(find.text('Task captured'), findsOneWidget); + expect(find.text('Draft outline'), findsOneWidget); + }); + + testWidgets('new task dialog validates an empty title without writing', ( + tester, + ) async { + final harness = _BacklogCommandHarness.empty(); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-new-task-button'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-new-task-create-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Title is required.'), findsOneWidget); + expect(harness.commandController.state, isA()); + expect(harness.store.currentTasks, isEmpty); + }); + + testWidgets('schedule action uses command controller and refreshes board', ( + tester, + ) async { + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'plan-week', + title: 'Plan next week', + durationMinutes: 30, + ), + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + expect(find.text('Plan next week'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-schedule-button')), + ); + await tester.pumpAndSettle(); + + expect(harness.commandController.state, isA()); + expect(find.text('Task scheduled'), findsOneWidget); + expect(find.byKey(const ValueKey('backlog-task-drawer')), findsNothing); + expect(find.byKey(const ValueKey('backlog-card-plan-week')), findsNothing); + }); + + testWidgets('suggested slot action schedules the indicated time', ( + tester, + ) async { + final existingFlexible = Task( + id: 'existing-flexible', + title: 'Existing flexible', + projectId: 'home', + type: TaskType.flexible, + status: TaskStatus.planned, + priority: PriorityLevel.medium, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + durationMinutes: 30, + scheduledStart: _now, + scheduledEnd: _now.add(const Duration(minutes: 30)), + createdAt: _now, + updatedAt: _now, + ); + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'plan-slot', + title: 'Plan slot', + durationMinutes: 30, + ), + existingFlexible, + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-card-plan-slot'))); + await tester.pumpAndSettle(); + final suggestedSlot = find.byKey( + const ValueKey('backlog-drawer-suggested-slot-plan-slot:primary'), + ); + await tester.ensureVisible(suggestedSlot); + await tester.tap(suggestedSlot); + await _settleAsyncUi(tester); + + expect(harness.commandController.state, isA()); + expect(find.text('Task scheduled'), findsOneWidget); + expect(find.byKey(const ValueKey('backlog-task-drawer')), findsNothing); + final scheduled = _taskById(harness.store.currentTasks, 'plan-slot'); + final pushed = _taskById(harness.store.currentTasks, 'existing-flexible'); + expect(scheduled.status, TaskStatus.planned); + expect(scheduled.scheduledStart, _now); + expect(scheduled.scheduledEnd, _now.add(const Duration(minutes: 30))); + expect(pushed.scheduledStart, _now.add(const Duration(minutes: 30))); + expect(pushed.scheduledEnd, _now.add(const Duration(minutes: 60))); + }); + + testWidgets( + 'schedule action explains missing duration without command write', + (tester) async { + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'missing-duration', + title: 'Estimate me', + durationMinutes: null, + ), + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await harness.boardController.selectTask('missing-duration'); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-schedule-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Add an estimate before scheduling.'), findsOneWidget); + expect(harness.commandController.state, isA()); + expect(find.byKey(const ValueKey('backlog-task-drawer')), findsOneWidget); + }, + ); + + testWidgets('break up action creates child tasks and refreshes board', ( + tester, + ) async { + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'outline-report', + title: 'Outline report', + durationMinutes: 30, + ), + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-card-outline-report'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-break-up-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Break up task'), findsOneWidget); + await tester.enterText( + find.byKey(const ValueKey('backlog-break-up-title-field-0')), + 'Draft sections', + ); + await tester.enterText( + find.byKey(const ValueKey('backlog-break-up-duration-field-0')), + '15', + ); + await tester.enterText( + find.byKey(const ValueKey('backlog-break-up-title-field-1')), + 'Review outline', + ); + await tester.tap( + find.byKey(const ValueKey('backlog-break-up-save-button')), + ); + await tester.pumpAndSettle(); + + expect(harness.commandController.state, isA()); + expect(find.text('Task broken up'), findsOneWidget); + expect(find.text('Draft sections'), findsWidgets); + expect(find.text('Review outline'), findsWidgets); + expect( + harness.store.currentTasks.where((task) => task.parentTaskId != null), + hasLength(2), + ); + }); + + testWidgets('break up dialog validates two child titles', (tester) async { + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'plan-event', + title: 'Plan event', + durationMinutes: 30, + ), + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-card-plan-event'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-break-up-button')), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('backlog-break-up-title-field-0')), + 'Choose date', + ); + await tester.tap( + find.byKey(const ValueKey('backlog-break-up-save-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Add at least two titled steps.'), findsOneWidget); + expect(harness.commandController.state, isA()); + expect( + harness.store.currentTasks.where((task) => task.parentTaskId != null), + isEmpty, + ); + }); + + testWidgets('push to someday refreshes selected detail and board bucket', ( + tester, + ) async { + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'maybe-later', + title: 'Maybe later', + durationMinutes: 30, + ), + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-card-maybe-later'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-push-someday-button')), + ); + await _settleAsyncUi(tester); + + expect(harness.commandController.state, isA()); + expect(find.text('Moved to Someday'), findsOneWidget); + expect( + _taskById(harness.store.currentTasks, 'maybe-later').backlogTags, + contains(BacklogTag.wishlist), + ); + expect(harness.boardController.selectedTaskDetail?.tags, ['someday']); + final state = harness.boardController.state as BacklogBoardReady; + final notNow = state.data.columns.singleWhere( + (column) => column.bucket == BacklogBoardBucket.notNow, + ); + expect(notNow.items.single.taskId, 'maybe-later'); + }); + + testWidgets('remove action can cancel then archive active backlog item', ( + tester, + ) async { + final harness = _BacklogCommandHarness.withTasks([ + _domainBacklogTask( + id: 'archive-me', + title: 'Archive me', + durationMinutes: 30, + ), + ]); + addTearDown(harness.dispose); + + await harness.boardController.load(); + await _pumpBacklogScreen( + tester, + harness.boardController, + commandController: harness.commandController, + ); + + await tester.tap(find.byKey(const ValueKey('backlog-card-archive-me'))); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-remove-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Remove from Backlog?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(harness.commandController.state, isA()); + expect( + _taskById(harness.store.currentTasks, 'archive-me').status, + TaskStatus.backlog, + ); + expect(find.byKey(const ValueKey('backlog-task-drawer')), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-remove-button')), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('backlog-remove-confirm-button')), + ); + await _settleAsyncUi(tester); + + expect(harness.commandController.state, isA()); + expect(find.text('Removed from active Backlog.'), findsOneWidget); + expect(find.byKey(const ValueKey('backlog-task-drawer')), findsNothing); + expect(find.byKey(const ValueKey('backlog-card-archive-me')), findsNothing); + expect( + _taskById(harness.store.currentTasks, 'archive-me').status, + TaskStatus.noLongerRelevant, + ); + }); + + testWidgets('controls update board read requests', (tester) async { + final requests = []; + final item = _boardItemWithChildren(); + final board = _boardWithItem( + item, + projectOptions: const [ + ProjectOptionReadModel( + projectId: 'work', + name: 'Work', + colorToken: 'project-work', + isArchived: false, + ), + ], + ); + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + readBoard: (request) async { + requests.add(request); + return ApplicationResult.success(board); + }, + readTaskDetail: (taskId, localDate) async { + return ApplicationResult.success(_detailFor(item)); + }, + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + ); + addTearDown(controller.dispose); + addTearDown(selectedDates.dispose); + + await controller.load(); + await _pumpBacklogScreen(tester, controller); + + await tester.enterText( + find.byKey(const ValueKey('backlog-search-field')), + 'course', + ); + await tester.pumpAndSettle(); + + expect(requests.last.searchText, 'course'); + expect(find.byKey(const ValueKey('backlog-search-clear')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-search-clear'))); + await tester.pumpAndSettle(); + + expect(requests.last.searchText, isNull); + + await tester.tap(find.byKey(const ValueKey('backlog-sort-menu-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reward vs Effort').last); + await tester.pumpAndSettle(); + + expect(requests.last.sortKey, BacklogSortKey.rewardVsEffort); + expect(find.text('Sort: Reward'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-group-menu-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Project').last); + await tester.pumpAndSettle(); + + expect(requests.last.groupMode, BacklogBoardGroupMode.project); + expect(find.text('Group: Project'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-filter-button'))); + await tester.pumpAndSettle(); + + Future tapFilterCheckbox(String label) async { + final checkbox = find.widgetWithText(CheckboxListTile, label); + await tester.ensureVisible(checkbox); + await tester.tap(checkbox); + await tester.pump(); + } + + await tapFilterCheckbox('High priority'); + await tapFilterCheckbox('Work'); + await tapFilterCheckbox('0-30 min'); + await tapFilterCheckbox('Need Time Block'); + await tapFilterCheckbox('No reward set'); + await tester.tap(find.text('Apply')); + await tester.pumpAndSettle(); + + expect(requests.last.filters, [BacklogFilter.noRewardSet]); + expect( + requests.last.boardFilters.priorityGroups, + contains(BacklogBoardPriorityFilter.high), + ); + expect(requests.last.boardFilters.projectIds, contains('work')); + expect( + requests.last.boardFilters.durationBuckets, + contains(BacklogBoardDurationFilter.zeroToThirty), + ); + expect( + requests.last.boardFilters.buckets, + contains(BacklogBoardBucket.needTimeBlock), + ); + expect(find.text('Filters 5'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('backlog-filter-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Clear')); + await tester.pumpAndSettle(); + + expect(requests.last.filters, isEmpty); + expect(requests.last.boardFilters.isEmpty, isTrue); + expect(find.text('Filters'), findsOneWidget); + }); + + testWidgets('layout avoids overflow at review desktop widths', ( + tester, + ) async { + final item = _boardItemWithChildren(); + final board = _boardWithItem(item); + final selectedDates = SelectedDateController( + initialDate: CivilDate(2026, 7, 7), + ); + final controller = BacklogBoardController( + readBoard: (_) async => ApplicationResult.success(board), + readTaskDetail: (taskId, localDate) async { + return ApplicationResult.success(_detailFor(item)); + }, + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + ); + addTearDown(controller.dispose); + addTearDown(selectedDates.dispose); + + await controller.load(); + for (final surfaceSize in _reviewSurfaceSizes) { + await _pumpBacklogScreen( + tester, + controller, + surfaceSize: surfaceSize, + frameSize: Size(surfaceSize.width - 24, surfaceSize.height - 40), + ); + expect( + tester.takeException(), + isNull, + reason: 'Unexpected layout exception at $surfaceSize.', + ); + } + + await controller.selectTask('plan-week'); + await _pumpBacklogScreen( + tester, + controller, + surfaceSize: const Size(1100, 760), + frameSize: const Size(1076, 720), + ); + expect(find.byKey(const ValueKey('backlog-task-drawer')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +/// Pumps [controller] inside the dark FocusFlow app shell test harness. +Future _pumpBacklogScreen( + WidgetTester tester, + BacklogBoardController controller, { + SchedulerCommandController? commandController, + Size surfaceSize = const Size(1320, 820), + Size frameSize = const Size(1300, 780), +}) async { + await tester.binding.setSurfaceSize(surfaceSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: FocusFlowTheme.dark(), + home: Scaffold( + body: SizedBox( + width: frameSize.width, + height: frameSize.height, + child: BacklogBoardScreen( + controller: controller, + commandController: commandController, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +/// Desktop widths used for final Backlog layout smoke tests. +const _reviewSurfaceSizes = [ + Size(1672, 940), + Size(1440, 860), + Size(1280, 820), + Size(1100, 760), +]; + +/// Lets unawaited UI command futures finish and repaint their final state. +Future _settleAsyncUi(WidgetTester tester) async { + await tester.pumpAndSettle(); + await tester.runAsync(() async { + await Future.delayed(Duration.zero); + }); + await tester.pumpAndSettle(); +} + +/// Test harness that wires Backlog UI controllers to in-memory scheduler use cases. +class _BacklogCommandHarness { + /// Creates a harness from initial [tasks]. + _BacklogCommandHarness._({required List tasks}) + : selectedDates = SelectedDateController(initialDate: _testDate), + store = InMemoryApplicationUnitOfWork( + initialProjects: [ + ProjectProfile(id: 'home', name: 'Home', colorKey: 'home'), + ], + initialOwnerSettings: [ + OwnerSettings(ownerId: _ownerId, timeZoneId: 'UTC'), + ], + initialTasks: tasks, + ) { + managementUseCases = V1ApplicationManagementUseCases( + applicationStore: store, + ); + commandUseCases = V1ApplicationCommandUseCases( + applicationStore: store, + timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + ); + boardController = BacklogBoardController( + selectedDates: selectedDates, + dateLabelFor: (date) => date.toIsoString(), + readBoard: (request) { + return managementUseCases.getBacklogBoard( + GetBacklogBoardRequest( + context: _context('read-board'), + localDate: request.localDate, + searchText: request.searchText, + filters: request.filters, + boardFilters: request.boardFilters, + sortKey: request.sortKey, + groupMode: request.groupMode, + ), + ); + }, + readTaskDetail: (taskId, localDate) { + return managementUseCases.getBacklogTaskDetail( + GetBacklogTaskDetailRequest( + context: _context('read-detail-$taskId'), + localDate: localDate, + taskId: taskId, + ), + ); + }, + ); + commandController = SchedulerCommandController( + commands: commandUseCases, + management: managementUseCases, + contextFor: _context, + selectedDate: () => selectedDates.date, + refreshReads: boardController.load, + quickCaptureProjectId: 'home', + operationIdPrefix: 'backlog-widget-command', + now: () => _now, + ); + } + + /// Creates an empty harness. + factory _BacklogCommandHarness.empty() { + return _BacklogCommandHarness._(tasks: const []); + } + + /// Creates a harness seeded with [tasks]. + factory _BacklogCommandHarness.withTasks(List tasks) { + return _BacklogCommandHarness._(tasks: tasks); + } + + /// Selected date controller. + final SelectedDateController selectedDates; + + /// In-memory scheduler application store. + final InMemoryApplicationUnitOfWork store; + + /// Management use cases. + late final V1ApplicationManagementUseCases managementUseCases; + + /// Command use cases. + late final V1ApplicationCommandUseCases commandUseCases; + + /// Backlog board controller. + late final BacklogBoardController boardController; + + /// Scheduler command controller. + late final SchedulerCommandController commandController; + + /// Releases harness-owned controllers. + void dispose() { + commandController.dispose(); + boardController.dispose(); + selectedDates.dispose(); + } +} + +/// Owner id used by command integration widget tests. +const _ownerId = 'owner-1'; + +/// Selected date used by command integration widget tests. +final _testDate = CivilDate(2026, 7, 7); + +/// Stable command clock used by command integration widget tests. +final _now = DateTime.utc(2026, 7, 7, 15); + +/// Creates an operation context for [operationId]. +ApplicationOperationContext _context(String operationId, {DateTime? now}) { + return ApplicationOperationContext.start( + operationId: operationId, + ownerTimeZone: OwnerTimeZoneContext(ownerId: _ownerId, timeZoneId: 'UTC'), + clock: FixedClock(now ?? _now), + idGenerator: SequentialIdGenerator(prefix: operationId), + ); +} + +/// Builds a domain Backlog task for command integration widget tests. +Task _domainBacklogTask({ + required String id, + required String title, + required int? durationMinutes, +}) { + return Task( + id: id, + title: title, + projectId: 'home', + type: TaskType.flexible, + status: TaskStatus.backlog, + priority: PriorityLevel.medium, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + durationMinutes: durationMinutes, + createdAt: _now, + updatedAt: _now, + ); +} + +/// Finds the task with [taskId] in [tasks]. +Task _taskById(List tasks, String taskId) { + return tasks.singleWhere((task) => task.id == taskId); +} + +/// Builds a board item with child preview rows for widget tests. +BacklogBoardItemReadModel _boardItemWithChildren() { + return BacklogBoardItemReadModel( + taskId: 'plan-week', + title: 'Plan next week', + subtitle: 'Outline priorities + tasks', + projectId: 'work', + projectName: 'Work', + projectColorToken: 'project-work', + durationMinutes: 30, + priority: PriorityLevel.high, + reward: RewardLevel.high, + difficulty: DifficultyLevel.medium, + bucket: BacklogBoardBucket.breakUpFirst, + bucketReason: 'Already has child-task planning.', + createdAt: DateTime.utc(2026, 7, 7), + updatedAt: DateTime.utc(2026, 7, 7), + childPreview: const [ + BacklogChildPreviewReadModel( + taskId: 'define-weekly-goals', + title: 'Define weekly goals', + durationMinutes: 15, + isCompleted: false, + ), + ], + tags: const ['someday'], + ); +} + +/// Builds a Backlog board containing [item] and the other stable empty columns. +BacklogBoardQueryResult _boardWithItem( + BacklogBoardItemReadModel item, { + List projectOptions = const [], +}) { + return BacklogBoardQueryResult( + ownerId: 'owner-1', + localDate: CivilDate(2026, 7, 7), + columns: [ + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.doNext, + title: 'Do Next', + subtitle: 'Ready when you have a small opening.', + accentToken: 'backlog-do-next', + items: const [], + ), + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.needTimeBlock, + title: 'Need Time Block', + subtitle: 'Give these a protected block.', + accentToken: 'backlog-time-block', + items: const [], + ), + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.breakUpFirst, + title: 'Break Up First', + subtitle: 'Split these before scheduling.', + accentToken: 'backlog-break-up', + items: [item], + ), + BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.notNow, + title: 'Not Now', + subtitle: 'Keep these out of today pressure.', + accentToken: 'backlog-not-now', + items: const [], + ), + ], + summary: BacklogBoardSummaryReadModel( + totalTaskCount: 1, + totalEstimatedMinutes: item.durationMinutes ?? 0, + priorityDistribution: BacklogDistributionReadModel( + title: 'By priority', + entries: const [ + BacklogDistributionEntryReadModel( + id: 'high', + label: 'High', + count: 1, + percentage: 1, + colorToken: 'priority-high', + ), + ], + ), + projectDistribution: BacklogDistributionReadModel( + title: 'By project', + entries: const [ + BacklogDistributionEntryReadModel( + id: 'work', + label: 'Work', + count: 1, + percentage: 1, + colorToken: 'project-work', + ), + ], + ), + durationDistribution: BacklogDistributionReadModel( + title: 'Duration distribution', + entries: const [], + ), + planningTip: 'Pick one task.', + ), + projectOptions: projectOptions, + ); +} + +/// Builds selected-task detail for [item]. +BacklogTaskDetailReadModel _detailFor(BacklogBoardItemReadModel item) { + return BacklogTaskDetailReadModel( + item: item, + addedLabel: 'Added today', + tags: item.tags, + projectOptions: const [], + suggestedSlots: const [], + ); +} diff --git a/apps/focus_flow_flutter/test/widgets/backlog_summary_panel_test.dart b/apps/focus_flow_flutter/test/widgets/backlog_summary_panel_test.dart new file mode 100644 index 0000000..e498808 --- /dev/null +++ b/apps/focus_flow_flutter/test/widgets/backlog_summary_panel_test.dart @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests Backlog summary panel rendering and collapse behavior. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; +import 'package:focus_flow_flutter/widgets/backlog/backlog_summary_panel.dart'; + +/// Runs Backlog summary panel widget tests. +void main() { + testWidgets('renders summary sections and toggles collapse state', ( + tester, + ) async { + await _pumpSummaryPanel(tester, _summary()); + + expect(find.text('Backlog summary'), findsOneWidget); + expect(find.text('24'), findsOneWidget); + expect(find.text('12h 15m'), findsOneWidget); + expect(find.text('Total tasks'), findsOneWidget); + expect(find.text('Total estimated time'), findsOneWidget); + expect(find.text('By priority'), findsOneWidget); + expect(find.text('High'), findsOneWidget); + expect(find.text('Medium'), findsOneWidget); + expect(find.text('Low'), findsOneWidget); + expect(find.text('By project'), findsOneWidget); + expect(find.text('Home'), findsOneWidget); + expect(find.text('Work'), findsOneWidget); + expect(find.text('Duration distribution'), findsOneWidget); + expect(find.text('0-30 min'), findsOneWidget); + expect(find.text('7 (29%)'), findsOneWidget); + expect(find.text('30-60 min'), findsOneWidget); + expect(find.text('9 (38%)'), findsOneWidget); + expect(find.text('60-120 min'), findsOneWidget); + expect(find.text('6 (25%)'), findsOneWidget); + expect(find.text('120+ min'), findsOneWidget); + expect(find.text('2 (8%)'), findsOneWidget); + expect(find.text('Planning tip'), findsOneWidget); + expect(find.text(_planningTip), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('backlog-summary-collapse-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Backlog summary'), findsOneWidget); + expect(find.text('By priority'), findsNothing); + expect(find.text('Duration distribution'), findsNothing); + expect(find.text(_planningTip), findsNothing); + + await tester.tap( + find.byKey(const ValueKey('backlog-summary-collapse-button')), + ); + await tester.pumpAndSettle(); + + expect(find.text('By priority'), findsOneWidget); + expect(find.text('Duration distribution'), findsOneWidget); + expect(find.text(_planningTip), findsOneWidget); + }); +} + +/// Planning guidance text used by the panel fixture. +const _planningTip = + 'Start with the small high-priority work, then reserve a block for the longer items.'; + +/// Pumps [summary] in a fixed-width dark FocusFlow test harness. +Future _pumpSummaryPanel( + WidgetTester tester, + BacklogBoardSummaryReadModel summary, +) async { + await tester.binding.setSurfaceSize(const Size(420, 760)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: FocusFlowTheme.dark(), + home: Scaffold( + body: Center( + child: SizedBox( + width: 320, + height: 720, + child: BacklogSummaryPanel(summary: summary), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +/// Builds a representative summary read model for panel tests. +BacklogBoardSummaryReadModel _summary() { + return BacklogBoardSummaryReadModel( + totalTaskCount: 24, + totalEstimatedMinutes: 735, + priorityDistribution: BacklogDistributionReadModel( + title: 'Priority', + entries: const [ + BacklogDistributionEntryReadModel( + id: 'high', + label: 'High', + count: 7, + percentage: 0.29, + colorToken: 'priority-high', + ), + BacklogDistributionEntryReadModel( + id: 'medium', + label: 'Medium', + count: 9, + percentage: 0.38, + colorToken: 'priority-medium', + ), + BacklogDistributionEntryReadModel( + id: 'low', + label: 'Low', + count: 8, + percentage: 0.33, + colorToken: 'priority-low', + ), + ], + ), + projectDistribution: BacklogDistributionReadModel( + title: 'Project', + entries: const [ + BacklogDistributionEntryReadModel( + id: 'home', + label: 'Home', + count: 10, + percentage: 0.42, + colorToken: 'project-home', + ), + BacklogDistributionEntryReadModel( + id: 'work', + label: 'Work', + count: 8, + percentage: 0.33, + colorToken: 'project-work', + ), + BacklogDistributionEntryReadModel( + id: 'personal', + label: 'Personal', + count: 6, + percentage: 0.25, + colorToken: 'project-personal', + ), + ], + ), + durationDistribution: BacklogDistributionReadModel( + title: 'Duration', + entries: const [ + BacklogDistributionEntryReadModel( + id: '0-30', + label: '0-30 min', + count: 7, + percentage: 0.29, + ), + BacklogDistributionEntryReadModel( + id: '30-60', + label: '30-60 min', + count: 9, + percentage: 0.38, + ), + BacklogDistributionEntryReadModel( + id: '60-120', + label: '60-120 min', + count: 6, + percentage: 0.25, + ), + BacklogDistributionEntryReadModel( + id: '120+', + label: '120+ min', + count: 2, + percentage: 0.08, + ), + ], + ), + planningTip: _planningTip, + ); +} diff --git a/apps/focus_flow_flutter/test/widgets/backlog_task_drawer_test.dart b/apps/focus_flow_flutter/test/widgets/backlog_task_drawer_test.dart new file mode 100644 index 0000000..520938b --- /dev/null +++ b/apps/focus_flow_flutter/test/widgets/backlog_task_drawer_test.dart @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +/// Tests Backlog task detail drawer rendering and callbacks. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; +import 'package:focus_flow_flutter/widgets/backlog/backlog_task_drawer.dart'; + +/// Runs Backlog task drawer widget tests. +void main() { + testWidgets('renders loaded detail sections and calls action callbacks', ( + tester, + ) async { + var closeCount = 0; + var scheduleCount = 0; + var breakUpCount = 0; + var somedayCount = 0; + var removeCount = 0; + var addTagCount = 0; + var viewDayCount = 0; + var suggestedSlotId = ''; + + await _pumpDrawer( + tester, + BacklogTaskDrawer( + selectedTaskId: 'review-q3-budget', + detail: _detail(), + loading: false, + errorMessage: null, + actionMessage: null, + onClose: () { + closeCount += 1; + }, + onSchedule: () { + scheduleCount += 1; + }, + onBreakUp: () { + breakUpCount += 1; + }, + onPushToSomeday: () { + somedayCount += 1; + }, + onRemove: () { + removeCount += 1; + }, + onSuggestedSlotPressed: (slot) { + suggestedSlotId = slot.id; + }, + onProjectPressed: () {}, + onAddTagPressed: () { + addTagCount += 1; + }, + onViewDayPressed: () { + viewDayCount += 1; + }, + ), + ); + + expect(find.text('Review Q3 budget'), findsOneWidget); + expect(find.text('Finance review and notes'), findsOneWidget); + expect(find.text('Added 2h ago'), findsOneWidget); + expect(find.text('Notes'), findsOneWidget); + expect(find.text(_notes), findsOneWidget); + expect(find.text('Project'), findsOneWidget); + expect(find.text('Work'), findsOneWidget); + expect(find.text('Tags'), findsOneWidget); + expect(find.text('finance'), findsOneWidget); + expect(find.text('review'), findsOneWidget); + expect(find.text('Duration'), findsOneWidget); + expect(find.text('60 min'), findsWidgets); + expect(find.text('Reward'), findsOneWidget); + expect(find.text('3/5'), findsOneWidget); + expect(find.text('Satisfying'), findsOneWidget); + expect(find.text('Difficulty'), findsOneWidget); + expect(find.text('2/5'), findsOneWidget); + expect(find.text('Easy'), findsOneWidget); + expect(find.text('Suggested slots'), findsOneWidget); + expect(find.text('Today'), findsNWidgets(2)); + expect(find.text('Tomorrow'), findsOneWidget); + expect(find.text('2:30 PM - 3:30 PM | 60 min'), findsOneWidget); + expect(find.text('Great Fit'), findsNWidgets(2)); + expect(find.text('Okay'), findsOneWidget); + + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-view-day-button')), + ); + final suggestedSlot = find.byKey( + const ValueKey('backlog-drawer-suggested-slot-slot-1'), + ); + await tester.ensureVisible(suggestedSlot); + await tester.pump(); + await tester.tap(suggestedSlot); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-add-tag-button')), + ); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-schedule-button')), + ); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-break-up-button')), + ); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-push-someday-button')), + ); + await tester.tap( + find.byKey(const ValueKey('backlog-drawer-remove-button')), + ); + await tester.tap(find.byKey(const ValueKey('backlog-drawer-close-button'))); + + expect(viewDayCount, 1); + expect(suggestedSlotId, 'slot-1'); + expect(addTagCount, 1); + expect(scheduleCount, 1); + expect(breakUpCount, 1); + expect(somedayCount, 1); + expect(removeCount, 1); + expect(closeCount, 1); + }); + + testWidgets('renders loading and error states without detail data', ( + tester, + ) async { + await _pumpDrawer( + tester, + BacklogTaskDrawer( + selectedTaskId: 'task-1', + detail: null, + loading: true, + errorMessage: null, + actionMessage: null, + onClose: () {}, + onSchedule: () {}, + onBreakUp: () {}, + onPushToSomeday: () {}, + onRemove: () {}, + onSuggestedSlotPressed: (_) {}, + onProjectPressed: () {}, + onAddTagPressed: () {}, + onViewDayPressed: () {}, + ), + ); + + expect(find.text('Loading task'), findsOneWidget); + expect(find.text('Preparing task-1 details.'), findsOneWidget); + + await _pumpDrawer( + tester, + BacklogTaskDrawer( + selectedTaskId: 'task-1', + detail: null, + loading: false, + errorMessage: 'taskNotFound', + actionMessage: null, + onClose: () {}, + onSchedule: () {}, + onBreakUp: () {}, + onPushToSomeday: () {}, + onRemove: () {}, + onSuggestedSlotPressed: (_) {}, + onProjectPressed: () {}, + onAddTagPressed: () {}, + onViewDayPressed: () {}, + ), + ); + + expect(find.text('Could not load task details'), findsOneWidget); + expect(find.text('taskNotFound'), findsOneWidget); + }); +} + +/// Notes fixture text used by the drawer test. +const _notes = + 'Scan numbers and compare against plan. Note any variances and prepare talking points.'; + +/// Pumps [drawer] in a fixed-size dark FocusFlow test harness. +Future _pumpDrawer(WidgetTester tester, Widget drawer) async { + await tester.binding.setSurfaceSize(const Size(520, 860)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: FocusFlowTheme.dark(), + home: Scaffold( + body: Align( + alignment: Alignment.centerRight, + child: SizedBox(width: 466, height: 820, child: drawer), + ), + ), + ), + ); + await tester.pump(); +} + +/// Builds a loaded drawer detail fixture. +BacklogTaskDetailReadModel _detail() { + return BacklogTaskDetailReadModel( + item: BacklogBoardItemReadModel( + taskId: 'review-q3-budget', + title: 'Review Q3 budget', + subtitle: 'Finance review and notes', + projectId: 'work', + projectName: 'Work', + projectColorToken: 'project-work', + durationMinutes: 60, + priority: PriorityLevel.high, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + bucket: BacklogBoardBucket.needTimeBlock, + bucketReason: 'Give this a protected block.', + createdAt: DateTime.utc(2026, 7, 7, 19), + updatedAt: DateTime.utc(2026, 7, 7, 19), + childPreview: const [], + tags: const ['finance', 'review'], + ), + notes: _notes, + addedLabel: 'Added 2h ago', + tags: const ['finance', 'review'], + projectOptions: const [ + ProjectOptionReadModel( + projectId: 'work', + name: 'Work', + colorToken: 'project-work', + isArchived: false, + ), + ], + suggestedSlots: [ + SuggestedSlotReadModel( + id: 'slot-1', + startUtc: DateTime.utc(2026, 7, 7, 14, 30), + endUtc: DateTime.utc(2026, 7, 7, 15, 30), + localDateLabel: 'Today', + startText: '2:30 PM', + endText: '3:30 PM', + durationMinutes: 60, + fitLabel: 'Great Fit', + fitSeverityToken: 'fit-great', + isPrimary: true, + ), + SuggestedSlotReadModel( + id: 'slot-2', + startUtc: DateTime.utc(2026, 7, 7, 18), + endUtc: DateTime.utc(2026, 7, 7, 19), + localDateLabel: 'Today', + startText: '6:00 PM', + endText: '7:00 PM', + durationMinutes: 60, + fitLabel: 'Okay', + fitSeverityToken: 'fit-okay', + isPrimary: false, + ), + SuggestedSlotReadModel( + id: 'slot-3', + startUtc: DateTime.utc(2026, 7, 8, 10), + endUtc: DateTime.utc(2026, 7, 8, 11), + localDateLabel: 'Tomorrow', + startText: '10:00 AM', + endText: '11:00 AM', + durationMinutes: 60, + fitLabel: 'Great Fit', + fitSeverityToken: 'fit-great', + isPrimary: false, + ), + ], + ); +} diff --git a/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_002_SQLite_Document_Schema_V1.md b/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_002_SQLite_Document_Schema_V1.md new file mode 100644 index 0000000..a090eac --- /dev/null +++ b/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_002_SQLite_Document_Schema_V1.md @@ -0,0 +1,38 @@ + + + +# V1 ADR 002: SQLite Document Schema V1 + +Status: **Accepted** +Date: 2026-06-26 + +## Context +The project pivoted from MongoDB to **SQLite‑first** persistence. +We need a stable, versioned relational schema that maps cleanly to the domain +objects while staying behind the repository abstraction. Drift will manage +migrations. + +## Decision +* **Database file**: `adhd_scheduler.sqlite` in the user data directory. +* **Schema version**: `1` (managed by Drift). +* **Tables** + +| Table | Purpose | Key fields | +|-------|---------|------------| +| `tasks` | Authoritative task rows | `id TEXT PRIMARY KEY`, `owner_id TEXT`, `project_id`, `parent_id`, `type`, `status`, `priority`, `reward`, `difficulty`, `scheduled_start_utc`, `scheduled_end_utc`, `actual_start_utc`, `actual_end_utc`, `completed_at_utc`, `revision INT`, `created_at_utc`, `updated_at_utc`, `backlog_entered_at_utc`, `backlog_entered_provenance TEXT` | +| `projects` | Project configuration | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `color_key`, config defaults …, `archived_at_utc`, `revision` | +| `locked_blocks` | Recurring / one‑off locked time | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `date TEXT`, `start_time TEXT`, `end_time TEXT`, `recurrence_json`, `hidden_by_default INT`, `archived_at_utc`, `revision` | +| `locked_overrides` | Date‑scoped overrides | `id TEXT PRIMARY KEY`, `owner_id`, `locked_block_id`, `date TEXT`, `type`, JSON fields | +| `settings` | One row per owner | `owner_id TEXT PRIMARY KEY`, `timezone_id`, `day_start_minutes`, `day_end_minutes`, `compact_mode INT`, `revision` | +| `activities` | Append‑only internal events | `id TEXT PRIMARY KEY`, `owner_id`, `task_id`, `code`, `occurred_at_utc`, `metadata_json` | +| `snapshots` | Bounded diagnostic schedule snapshots | `id TEXT PRIMARY KEY`, `owner_id`, `operation_name`, `source_date TEXT`, `window_json`, `notice_json`, `changes_json`, `retention_expires_utc` | + +* All timestamps are stored as **UTC**. +* Optimistic concurrency: every mutable row carries `revision`. Updates must + include `WHERE revision = :expected` and increment on success. +* Drift migrations are generated and tested; downgrades are not supported. + +## Consequences +* Domain objects remain unchanged; adapters translate. +* Backup library (Block 24) can copy and encrypt this single file. +* Repository contract tests assert schema integrity via Drift introspection. diff --git a/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_005_Export_Backup_Boundary.md b/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_005_Export_Backup_Boundary.md new file mode 100644 index 0000000..17dcd92 --- /dev/null +++ b/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_005_Export_Backup_Boundary.md @@ -0,0 +1,12 @@ + + + +# V1 ADR 005: Export & Backup Boundary + +Status: **Accepted** +Date: 2026-06-26 + +* **Backup** – passphrase‑encrypted SQLite copy (`.sqlite.aes` via AES‑256‑GCM, PBKDF2 200 k rounds). +* **Readable export** – JSON and CSV via `ExportController`. + +No open questions remain. diff --git a/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_006_Schedule_Snapshot_Policy.md b/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_006_Schedule_Snapshot_Policy.md new file mode 100644 index 0000000..83d39f2 --- /dev/null +++ b/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_006_Schedule_Snapshot_Policy.md @@ -0,0 +1,11 @@ + + + +# V1 ADR 006: Schedule Snapshot Policy + +Status: **Accepted** +Date: 2026-06-26 + +Persist committed diagnostic snapshots only; bounded retention 30 days or until notices acknowledged. + +No open questions remain. diff --git a/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_008_Notification_Abstraction.md b/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_008_Notification_Abstraction.md new file mode 100644 index 0000000..688a251 --- /dev/null +++ b/archive/Archived plans/2026-07-07 stale completed ADRs/V1_ADR_008_Notification_Abstraction.md @@ -0,0 +1,11 @@ + + + +# V1 ADR 008: Notification Abstraction + +Status: **Accepted** +Date: 2026-06-26 + +`NotificationAdapter` interface abstracts scheduling/cancellation and feedback stream. Desktop implementation in `scheduler_notifications_desktop`; Fake adapter for tests. + +No open questions remain. diff --git a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart index 1068181..73241ff 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/codes/application_command_code.dart @@ -17,6 +17,10 @@ enum ApplicationCommandCode { /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. scheduleBacklogItemToNextAvailableSlot, + /// Selects the `scheduleBacklogItemAtSuggestedSlot` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + scheduleBacklogItemAtSuggestedSlot, + /// Selects the `pushFlexibleToNextAvailableSlot` option from this enum. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. pushFlexibleToNextAvailableSlot, @@ -29,6 +33,14 @@ enum ApplicationCommandCode { /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. moveFlexibleToBacklog, + /// Selects the `pushBacklogTaskToSomeday` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + pushBacklogTaskToSomeday, + + /// Selects the `archiveBacklogTask` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + archiveBacklogTask, + /// Selects the `removeTask` option from this enum. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. removeTask, diff --git a/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart b/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart index 9e1be77..4c4a794 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/planning/planning_state.dart @@ -229,6 +229,10 @@ List _changedTaskIds(List beforeTasks, List afterTasks) { /// Top-level helper that performs the `_taskChanged` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +/// +/// The comparison must include scheduler-visible metadata, not only scheduling +/// placement fields, because application commands persist only the tasks this +/// helper reports as changed. bool _taskChanged(Task before, Task after) { return before.title != after.title || before.projectId != after.projectId || @@ -243,11 +247,22 @@ bool _taskChanged(Task before, Task after) { !_sameNullableInstant(before.actualStart, after.actualStart) || !_sameNullableInstant(before.actualEnd, after.actualEnd) || !_sameNullableInstant(before.completedAt, after.completedAt) || + !_sameNullableInstant(before.backlogEnteredAt, after.backlogEnteredAt) || + before.backlogEnteredAtProvenance != after.backlogEnteredAtProvenance || before.parentTaskId != after.parentTaskId || + !_sameBacklogTags(before.backlogTags, after.backlogTags) || + before.reminderOverride != after.reminderOverride || + !_sameNullableInstant(before.createdAt, after.createdAt) || !_sameNullableInstant(before.updatedAt, after.updatedAt) || before.stats != after.stats; } +/// Top-level helper that performs the `_sameBacklogTags` operation for this file. +/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. +bool _sameBacklogTags(Set before, Set after) { + return before.length == after.length && before.containsAll(after); +} + /// Top-level helper that performs the `_sameNullableInstant` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. bool _sameNullableInstant(DateTime? first, DateTime? second) { diff --git a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart index 15bc738..7267db7 100644 --- a/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_commands/use_cases/v1_application_command_use_cases.dart @@ -285,6 +285,88 @@ class V1ApplicationCommandUseCases { ); } + /// Schedule an existing backlog item into a selected suggested slot. + Future> + scheduleBacklogItemAtSuggestedSlot({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + required DateTime scheduledStart, + required DateTime scheduledEnd, + DateTime? expectedUpdatedAt, + }) { + const commandCode = + ApplicationCommandCode.scheduleBacklogItemAtSuggestedSlot; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + logger.debug(() => + 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} localDate=${localDate.toIsoString()} ' + 'taskId=$taskId scheduledStart=${scheduledStart.toIso8601String()} ' + 'scheduledEnd=${scheduledEnd.toIso8601String()}'); + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + logger.finer(() => + 'Planning-state task lookup missed; reading by id. ' + 'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}'); + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + + final schedulingResult = + const SchedulingEngine().insertBacklogTaskAtRequestedSlot( + input: state.input, + taskId: taskId, + requestedStart: scheduledStart.toUtc(), + requestedEnd: scheduledEnd.toUtc(), + updatedAt: context.now, + ); + final schedulingFailure = _failureForSchedulingResult( + schedulingResult, + entityId: taskId, + ); + if (schedulingFailure != null) { + return ApplicationResult.failure(schedulingFailure); + } + + final activities = _activitiesForSchedulingChanges( + result: schedulingResult, + beforeTasks: state.tasks, + operationId: context.operationId, + occurredAt: context.now, + selectedTaskId: taskId, + selectedActivityCode: TaskActivityCode.restoredFromBacklog, + movedActivityCode: TaskActivityCode.automaticallyPushed, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: schedulingResult.tasks, + activities: activities, + schedulingResult: schedulingResult, + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: _taskIdsFromChanges(schedulingResult.changes), + ), + ); + }, + ); + } + /// Push a planned flexible task later in the same local day. Future> pushFlexibleToNextAvailableSlot({ @@ -375,6 +457,106 @@ class V1ApplicationCommandUseCases { ); } + /// Add the Someday/Wishlist backlog tag to an active Backlog task. + Future> pushBacklogTaskToSomeday({ + required ApplicationOperationContext context, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.pushBacklogTaskToSomeday; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + logger.debug( + () => 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} taskId=$taskId'); + final task = await repositories.tasks.findById(taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + if (task.status != TaskStatus.backlog) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: 'notBacklogTask', + ); + } + final updatedTags = {...task.backlogTags, BacklogTag.wishlist}; + final updated = task.copyWith( + backlogTags: updatedTags, + updatedAt: context.now, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: [task], + afterTasks: [updated], + activities: const [], + readHint: ApplicationCommandReadHint( + affectedTaskIds: [taskId], + refreshToday: false, + ), + ); + }, + ); + } + + /// Hide an active Backlog task without deleting its persisted record. + Future> archiveBacklogTask({ + required ApplicationOperationContext context, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.archiveBacklogTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + logger.debug( + () => 'Application command executing. command=${commandCode.name} ' + 'operationId=${context.operationId} taskId=$taskId'); + final task = await repositories.tasks.findById(taskId); + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + if (task.status != TaskStatus.backlog) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: 'notBacklogTask', + ); + } + final archived = task.copyWith( + status: TaskStatus.noLongerRelevant, + updatedAt: context.now, + clearSchedule: true, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: [task], + afterTasks: [archived], + activities: const [], + readHint: ApplicationCommandReadHint( + affectedTaskIds: [taskId], + refreshToday: false, + ), + ); + }, + ); + } + /// Permanently remove a task from persistence. Future> removeTask({ required ApplicationOperationContext context, diff --git a/packages/scheduler_core/lib/src/application/application_management.dart b/packages/scheduler_core/lib/src/application/application_management.dart index 258d5e8..452605c 100644 --- a/packages/scheduler_core/lib/src/application/application_management.dart +++ b/packages/scheduler_core/lib/src/application/application_management.dart @@ -18,17 +18,34 @@ import '../domain/models.dart'; import '../domain/project_statistics.dart'; import '../domain/time_contracts.dart'; import '../logging/focus_flow_logger.dart'; +import '../persistence/repositories.dart'; +import '../scheduling/scheduling_engine.dart'; part 'application_management/codes/application_management_command_code.dart'; part 'application_management/codes/owner_settings_warning_code.dart'; +part 'application_management/requests/backlog_board_filter_selection.dart'; +part 'application_management/requests/backlog_board_group_mode.dart'; +part 'application_management/requests/get_backlog_board_request.dart'; part 'application_management/requests/get_backlog_request.dart'; +part 'application_management/requests/get_backlog_task_detail_request.dart'; part 'application_management/read_models/backlog_item_read_model.dart'; part 'application_management/read_models/backlog_query_result.dart'; +part 'application_management/read_models/backlog_board/backlog_board_column_read_model.dart'; +part 'application_management/read_models/backlog_board/backlog_board_item_read_model.dart'; +part 'application_management/read_models/backlog_board/backlog_board_query_result.dart'; +part 'application_management/read_models/backlog_board/backlog_board_summary_read_model.dart'; +part 'application_management/read_models/backlog_board/backlog_child_preview_read_model.dart'; +part 'application_management/read_models/backlog_board/backlog_distribution_entry_read_model.dart'; +part 'application_management/read_models/backlog_board/backlog_distribution_read_model.dart'; +part 'application_management/read_models/backlog_board/backlog_task_detail_read_model.dart'; +part 'application_management/read_models/backlog_board/project_option_read_model.dart'; +part 'application_management/read_models/backlog_board/suggested_slot_read_model.dart'; part 'application_management/requests/get_project_defaults_request.dart'; part 'application_management/read_models/project_defaults_query_result.dart'; part 'application_management/drafts/project_profile_draft.dart'; part 'application_management/updates/project_profile_update.dart'; part 'application_management/drafts/locked_block_draft.dart'; part 'application_management/updates/locked_block_update.dart'; +part 'application_management/requests/get_owner_settings_request.dart'; part 'application_management/results/owner_settings_update_result.dart'; part 'application_management/updates/owner_settings_update.dart'; part 'application_management/results/notice_acknowledgement_result.dart'; diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_column_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_column_read_model.dart new file mode 100644 index 0000000..5ab53c9 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_column_read_model.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_management.dart'; + +/// One column in the Backlog board. +class BacklogBoardColumnReadModel { + /// Creates a board column with immutable [items]. + BacklogBoardColumnReadModel({ + required this.bucket, + required this.title, + required this.subtitle, + required this.accentToken, + required List items, + int? count, + }) : items = List.unmodifiable(items), + count = count ?? items.length; + + /// Bucket represented by this column. + final BacklogBoardBucket bucket; + + /// Display title for the column header. + final String title; + + /// Short display subtitle for the column header. + final String subtitle; + + /// UI color token used by Flutter to style the column accent. + final String accentToken; + + /// Count shown in the column header. + final int count; + + /// Items rendered in this column. + final List items; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_item_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_item_read_model.dart new file mode 100644 index 0000000..9ed5128 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_item_read_model.dart @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_management.dart'; + +/// Card-level read model for one Backlog board task. +class BacklogBoardItemReadModel { + /// Creates a Backlog board item from display-ready task metadata. + BacklogBoardItemReadModel({ + required this.taskId, + required this.title, + required this.projectId, + required this.projectName, + required this.projectColorToken, + required this.reward, + required this.difficulty, + required this.bucket, + required this.bucketReason, + required this.createdAt, + required this.updatedAt, + required List childPreview, + required List tags, + this.subtitle, + this.durationMinutes, + this.priority, + bool? hasChildren, + }) : childPreview = + List.unmodifiable(childPreview), + tags = List.unmodifiable(tags), + hasChildren = hasChildren ?? childPreview.isNotEmpty; + + /// Stable task id used for selection and commands. + final String taskId; + + /// User-facing task title. + final String title; + + /// Optional subtitle or notes preview for card display. + final String? subtitle; + + /// Owning project id. + final String projectId; + + /// Display name for the owning project. + final String projectName; + + /// UI color token for the owning project. + final String projectColorToken; + + /// Estimated task duration in minutes, if captured. + final int? durationMinutes; + + /// Optional task priority. + final PriorityLevel? priority; + + /// Expected reward or payoff. + final RewardLevel reward; + + /// Expected activation difficulty. + final DifficultyLevel difficulty; + + /// Board bucket selected by scheduler policy. + final BacklogBoardBucket bucket; + + /// User-facing reason for [bucket]. + final String bucketReason; + + /// Original task creation timestamp. + final DateTime createdAt; + + /// Last task update timestamp. + final DateTime updatedAt; + + /// Whether the task has child task metadata. + final bool hasChildren; + + /// Preview rows for direct child tasks. + final List childPreview; + + /// Display-ready task-local tag labels. + final List tags; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_query_result.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_query_result.dart new file mode 100644 index 0000000..51eceed --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_query_result.dart @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_management.dart'; + +/// Query result for the Backlog board surface. +class BacklogBoardQueryResult { + /// Creates a Backlog board result from summary, columns, and project options. + BacklogBoardQueryResult({ + required this.ownerId, + required this.localDate, + required this.summary, + required List columns, + required List projectOptions, + }) : columns = List.unmodifiable(columns), + projectOptions = List.unmodifiable( + projectOptions, + ); + + /// Authorization-neutral owner scope used for the read. + final String ownerId; + + /// Owner-local planning date used for suggested slot previews. + final CivilDate localDate; + + /// Summary counts and planning copy derived from the same filtered item set. + final BacklogBoardSummaryReadModel summary; + + /// Stable ordered board columns. + final List columns; + + /// Project choices available to board and drawer controls. + final List projectOptions; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_summary_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_summary_read_model.dart new file mode 100644 index 0000000..d06f59d --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_board_summary_read_model.dart @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_management.dart'; + +/// Summary panel read model for the Backlog board. +class BacklogBoardSummaryReadModel { + /// Creates a Backlog summary from count, estimate, and distribution data. + const BacklogBoardSummaryReadModel({ + required this.totalTaskCount, + required this.totalEstimatedMinutes, + required this.priorityDistribution, + required this.projectDistribution, + required this.durationDistribution, + required this.planningTip, + }); + + /// Total number of tasks represented by the filtered board. + final int totalTaskCount; + + /// Sum of captured duration estimates across the filtered board. + final int totalEstimatedMinutes; + + /// Count distribution by priority label. + final BacklogDistributionReadModel priorityDistribution; + + /// Count distribution by project label. + final BacklogDistributionReadModel projectDistribution; + + /// Count and percentage distribution by duration bucket. + final BacklogDistributionReadModel durationDistribution; + + /// Display copy for the planning tip panel. + final String planningTip; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_child_preview_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_child_preview_read_model.dart new file mode 100644 index 0000000..d7f3444 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_child_preview_read_model.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_management.dart'; + +/// Compact preview for a direct child task. +class BacklogChildPreviewReadModel { + /// Creates a child task preview row. + const BacklogChildPreviewReadModel({ + required this.taskId, + required this.title, + required this.isCompleted, + this.durationMinutes, + this.statusLabel, + }); + + /// Stable child task id. + final String taskId; + + /// User-facing child task title. + final String title; + + /// Estimated child task duration in minutes, if captured. + final int? durationMinutes; + + /// Whether the child task is completed. + final bool isCompleted; + + /// Optional display label for the child task lifecycle state. + final String? statusLabel; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_distribution_entry_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_distribution_entry_read_model.dart new file mode 100644 index 0000000..cc82a30 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_distribution_entry_read_model.dart @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_management.dart'; + +/// One entry in a Backlog summary distribution. +class BacklogDistributionEntryReadModel { + /// Creates a distribution entry. + const BacklogDistributionEntryReadModel({ + required this.id, + required this.label, + required this.count, + required this.percentage, + this.colorToken, + }); + + /// Stable entry id for tests and UI keys. + final String id; + + /// Display label for the entry. + final String label; + + /// Number of tasks in this entry. + final int count; + + /// Share of the parent distribution as a value from 0.0 to 1.0. + final double percentage; + + /// Optional UI color token for the entry. + final String? colorToken; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_distribution_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_distribution_read_model.dart new file mode 100644 index 0000000..5100dd3 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_distribution_read_model.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_management.dart'; + +/// Named distribution used by Backlog summary panels. +class BacklogDistributionReadModel { + /// Creates a distribution with immutable [entries]. + BacklogDistributionReadModel({ + required this.title, + required List entries, + int? totalCount, + }) : entries = List.unmodifiable( + entries, + ), + totalCount = totalCount ?? + entries.fold(0, (sum, entry) => sum + entry.count); + + /// Display title for this distribution. + final String title; + + /// Total item count represented by [entries]. + final int totalCount; + + /// Ordered display entries. + final List entries; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_task_detail_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_task_detail_read_model.dart new file mode 100644 index 0000000..11fb872 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/backlog_task_detail_read_model.dart @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_management.dart'; + +/// Drawer-level read model for a selected Backlog task. +class BacklogTaskDetailReadModel { + /// Creates a task detail result for the Backlog drawer. + BacklogTaskDetailReadModel({ + required this.item, + required this.addedLabel, + required List tags, + required List projectOptions, + required List suggestedSlots, + this.notes, + this.suggestedSlotUnavailableReason, + }) : tags = List.unmodifiable(tags), + projectOptions = List.unmodifiable( + projectOptions, + ), + suggestedSlots = List.unmodifiable( + suggestedSlots, + ); + + /// Board card model for the selected task. + final BacklogBoardItemReadModel item; + + /// Optional long-form notes for the drawer. + final String? notes; + + /// Display label describing when the task was added to Backlog. + final String addedLabel; + + /// Display-ready task-local tag labels. + final List tags; + + /// Available project choices for the selected task. + final List projectOptions; + + /// Non-mutating suggested schedule slots. + final List suggestedSlots; + + /// Display reason when slot suggestions are unavailable. + final String? suggestedSlotUnavailableReason; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/project_option_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/project_option_read_model.dart new file mode 100644 index 0000000..708b081 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/project_option_read_model.dart @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_management.dart'; + +/// Project option exposed to Backlog board and drawer controls. +class ProjectOptionReadModel { + /// Creates a project option read model. + const ProjectOptionReadModel({ + required this.projectId, + required this.name, + required this.colorToken, + this.isArchived = false, + }); + + /// Stable project id. + final String projectId; + + /// User-facing project name. + final String name; + + /// UI color token for the project. + final String colorToken; + + /// Whether this project is archived. + final bool isArchived; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/suggested_slot_read_model.dart b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/suggested_slot_read_model.dart new file mode 100644 index 0000000..0b98997 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/read_models/backlog_board/suggested_slot_read_model.dart @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../../application_management.dart'; + +/// Preview row for a possible Backlog scheduling slot. +class SuggestedSlotReadModel { + /// Creates a suggested slot preview. + const SuggestedSlotReadModel({ + required this.id, + required this.startUtc, + required this.endUtc, + required this.localDateLabel, + required this.startText, + required this.endText, + required this.durationMinutes, + required this.fitLabel, + required this.fitSeverityToken, + required this.isPrimary, + }); + + /// Stable suggestion id. + final String id; + + /// UTC instant where the suggested slot starts. + final DateTime startUtc; + + /// UTC instant where the suggested slot ends. + final DateTime endUtc; + + /// Display label for the owner-local date. + final String localDateLabel; + + /// Display-ready start time. + final String startText; + + /// Display-ready end time. + final String endText; + + /// Suggested slot length in minutes. + final int durationMinutes; + + /// Display label such as `Great Fit` or `Okay`. + final String fitLabel; + + /// UI severity token for the fit label. + final String fitSeverityToken; + + /// Whether this is the primary/default scheduling target. + final bool isPrimary; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_filter_selection.dart b/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_filter_selection.dart new file mode 100644 index 0000000..46632e1 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_filter_selection.dart @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Priority groups exposed by the Backlog board filter UI. +enum BacklogBoardPriorityFilter { + /// High and very high priority tasks. + high, + + /// Medium priority tasks. + medium, + + /// Low and very low priority tasks. + low, +} + +/// Duration buckets exposed by the Backlog board filter UI. +enum BacklogBoardDurationFilter { + /// Tasks estimated at thirty minutes or less. + zeroToThirty, + + /// Tasks estimated above thirty minutes and up to sixty minutes. + thirtyToSixty, + + /// Tasks estimated above sixty minutes and up to one hundred twenty minutes. + sixtyToOneTwenty, + + /// Tasks estimated above one hundred twenty minutes. + overOneTwenty, +} + +/// Board-specific filters that do not belong to the legacy list filter enum. +class BacklogBoardFilterSelection { + /// Creates a board filter selection from user-facing filter groups. + BacklogBoardFilterSelection({ + Iterable priorityGroups = + const [], + Iterable projectIds = const [], + Iterable durationBuckets = + const [], + Iterable buckets = const [], + this.missingDurationOnly = false, + }) : priorityGroups = Set.unmodifiable( + priorityGroups, + ), + projectIds = Set.unmodifiable(projectIds), + durationBuckets = Set.unmodifiable( + durationBuckets, + ), + buckets = Set.unmodifiable(buckets); + + /// Creates an empty board filter selection. + const BacklogBoardFilterSelection.empty() + : priorityGroups = const {}, + projectIds = const {}, + durationBuckets = const {}, + buckets = const {}, + missingDurationOnly = false; + + /// Selected priority groups. + final Set priorityGroups; + + /// Selected project ids. + final Set projectIds; + + /// Selected duration buckets. + final Set durationBuckets; + + /// Selected board buckets. + final Set buckets; + + /// Whether only tasks without a duration estimate should be returned. + final bool missingDurationOnly; + + /// Whether this selection contains no active board filters. + bool get isEmpty { + return priorityGroups.isEmpty && + projectIds.isEmpty && + durationBuckets.isEmpty && + buckets.isEmpty && + !missingDurationOnly; + } + + /// Number of active filter groups. + int get activeFilterCount { + return priorityGroups.length + + projectIds.length + + durationBuckets.length + + buckets.length + + (missingDurationOnly ? 1 : 0); + } +} diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_group_mode.dart b/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_group_mode.dart new file mode 100644 index 0000000..e12f9f1 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/backlog_board_group_mode.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Optional grouping mode requested by the Backlog board UI. +enum BacklogBoardGroupMode { + /// No additional grouping beyond the canonical board buckets. + none, + + /// Preserve board buckets while allowing project-aware ordering inside them. + project, + + /// Preserve board buckets while ordering by priority groups inside them. + priority, + + /// Preserve board buckets while ordering by duration groups inside them. + duration, +} diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_board_request.dart b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_board_request.dart new file mode 100644 index 0000000..8e5c2f8 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_board_request.dart @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Request for the Backlog board projection. +class GetBacklogBoardRequest { + /// Creates a board request with optional search, filters, sorting, and grouping. + GetBacklogBoardRequest({ + required this.context, + required this.localDate, + this.searchText, + List filters = const [], + BacklogBoardFilterSelection? boardFilters, + this.sortKey = BacklogSortKey.custom, + this.groupMode = BacklogBoardGroupMode.none, + }) : filters = List.unmodifiable(filters), + boardFilters = + boardFilters ?? const BacklogBoardFilterSelection.empty(); + + /// Read context containing owner scope and read instant. + final ApplicationOperationContext context; + + /// Owner-local date used for schedule preview context. + final CivilDate localDate; + + /// Optional user-entered search text. + final String? searchText; + + /// Optional user-facing backlog filters. + final List filters; + + /// Board-specific filters applied after bucket and card metadata resolution. + final BacklogBoardFilterSelection boardFilters; + + /// User-facing sort order used inside each board bucket. + final BacklogSortKey sortKey; + + /// Optional grouping mode requested by the UI. + final BacklogBoardGroupMode groupMode; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_task_detail_request.dart b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_task_detail_request.dart new file mode 100644 index 0000000..9f93bae --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/get_backlog_task_detail_request.dart @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Request for one Backlog task drawer projection. +class GetBacklogTaskDetailRequest { + /// Creates a detail request for [taskId]. + const GetBacklogTaskDetailRequest({ + required this.context, + required this.localDate, + required this.taskId, + }); + + /// Read context containing owner scope and read instant. + final ApplicationOperationContext context; + + /// Owner-local date used for schedule preview context. + final CivilDate localDate; + + /// Selected backlog task id. + final String taskId; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/get_owner_settings_request.dart b/packages/scheduler_core/lib/src/application/application_management/requests/get_owner_settings_request.dart new file mode 100644 index 0000000..cf52ed6 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/get_owner_settings_request.dart @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../application_management.dart'; + +/// Owner settings query request. +class GetOwnerSettingsRequest { + /// Creates a `GetOwnerSettingsRequest` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const GetOwnerSettingsRequest({required this.context}); + + /// Read context containing owner scope and read instant. + final ApplicationOperationContext context; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart index 1b8ea36..16ae9b6 100644 --- a/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart @@ -10,6 +10,9 @@ class V1ApplicationManagementUseCases { const V1ApplicationManagementUseCases({ required this.applicationStore, this.projectSuggestionService = const ProjectSuggestionService(), + this.timeZoneResolver = const FixedOffsetTimeZoneResolver.utc(), + this.resolutionOptions = const TimeZoneResolutionOptions(), + this.backlogBoardBucketPolicy = const BacklogBoardBucketPolicy(), }); /// Atomic application boundary. @@ -18,6 +21,15 @@ class V1ApplicationManagementUseCases { /// Learned project suggestion policy. final ProjectSuggestionService projectSuggestionService; + /// Resolver used for read-only planning previews. + final TimeZoneResolver timeZoneResolver; + + /// DST/nonexistent/repeated local time policy for read-only previews. + final TimeZoneResolutionOptions resolutionOptions; + + /// Canonical board bucket policy used by Backlog board reads. + final BacklogBoardBucketPolicy backlogBoardBucketPolicy; + /// Load backlog candidates through the repository status filter, then apply /// existing filter/sort/staleness policies in memory. Future> getBacklog( @@ -69,6 +81,639 @@ class V1ApplicationManagementUseCases { ); } + /// Load the Backlog board projection without mutating repositories. + Future> getBacklogBoard( + GetBacklogBoardRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final context = request.context; + final ownerId = context.ownerTimeZone.ownerId; + logger.debug(() => 'Loading backlog board. ownerId=$ownerId ' + 'localDate=${request.localDate.toIsoString()} ' + 'filterCount=${request.filters.length} ' + 'boardFilterCount=${request.boardFilters.activeFilterCount} ' + 'sortKey=${request.sortKey.name} ' + 'groupMode=${request.groupMode.name}'); + final settings = await _ownerSettingsOrDefault( + repositories, + context.ownerTimeZone, + ); + final projects = await _loadActiveProjects(repositories, ownerId); + final projectById = { + for (final project in projects) project.id: project, + }; + final candidates = await _loadBacklogCandidates(repositories, ownerId); + final filtered = _filterBacklogTasks( + tasks: candidates, + request: request, + settings: settings, + ); + final sorted = _sortBoardTasks( + tasks: filtered, + request: request, + settings: settings, + projectById: projectById, + ); + final childrenByParentId = await _loadChildrenByParentId( + repositories: repositories, + ownerId: ownerId, + parentTasks: sorted, + ); + final items = [ + for (final task in sorted) + _boardItemForTask( + task: task, + projectById: projectById, + childTasks: childrenByParentId[task.id] ?? const [], + ), + ]; + final searched = _searchBoardItems( + items: items, + searchText: request.searchText, + ); + final boardFiltered = _filterBoardItems( + items: searched, + filters: request.boardFilters, + ); + + logger.debug(() => 'Backlog board loaded. ownerId=$ownerId ' + 'candidateCount=${candidates.length} ' + 'itemCount=${boardFiltered.length}'); + return ApplicationResult.success( + BacklogBoardQueryResult( + ownerId: ownerId, + localDate: request.localDate, + summary: _summaryForBoardItems(boardFiltered), + columns: _columnsForBoardItems(boardFiltered), + projectOptions: _projectOptionsFor(projects), + ), + ); + }, + ); + } + + /// Load one Backlog task drawer projection without mutating repositories. + Future> getBacklogTaskDetail( + GetBacklogTaskDetailRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final context = request.context; + final ownerId = context.ownerTimeZone.ownerId; + logger.debug(() => 'Loading backlog task detail. ownerId=$ownerId ' + 'taskId=${request.taskId} localDate=${request.localDate.toIsoString()}'); + final record = await repositories.tasks.findRecordById(request.taskId); + if (record == null || + record.ownerId != ownerId || + !record.value.isBacklog) { + logger.warn(() => 'Backlog task detail not found. ' + 'ownerId=$ownerId taskId=${request.taskId}'); + return _failure( + ApplicationFailureCode.notFound, + entityId: request.taskId, + ); + } + final task = record.value; + final projects = await _loadActiveProjects(repositories, ownerId); + final projectById = { + for (final project in projects) project.id: project, + }; + final childrenByParentId = await _loadChildrenByParentId( + repositories: repositories, + ownerId: ownerId, + parentTasks: [task], + ); + final item = _boardItemForTask( + task: task, + projectById: projectById, + childTasks: childrenByParentId[task.id] ?? const [], + ); + final suggestions = await _suggestedSlotsForTask( + repositories: repositories, + context: context, + localDate: request.localDate, + task: task, + ); + + logger.debug(() => 'Backlog task detail loaded. ownerId=$ownerId ' + 'taskId=${task.id} suggestedSlotCount=${suggestions.length}'); + return ApplicationResult.success( + BacklogTaskDetailReadModel( + item: item, + notes: null, + addedLabel: _addedLabelFor(task, context.now), + tags: _readOnlyTagLabelsFor(task), + projectOptions: _projectOptionsFor(projects), + suggestedSlots: suggestions, + suggestedSlotUnavailableReason: + _suggestedSlotUnavailableReasonFor(task, suggestions), + ), + ); + }, + ); + } + + /// Load all active owner projects used by Backlog board reads. + Future> _loadActiveProjects( + ApplicationUnitOfWorkRepositories repositories, + String ownerId, + ) async { + final projects = []; + String? cursor; + do { + final page = await repositories.projects.findByOwner( + ownerId: ownerId, + page: RepositoryPageRequest(cursor: cursor, limit: 200), + ); + projects.addAll(page.items); + cursor = page.nextCursor; + } while (cursor != null); + + projects.sort((a, b) => a.name.compareTo(b.name)); + return List.unmodifiable(projects); + } + + /// Load all owner-scoped backlog candidates for the board. + Future> _loadBacklogCandidates( + ApplicationUnitOfWorkRepositories repositories, + String ownerId, + ) async { + final tasks = []; + String? cursor; + do { + final page = await repositories.tasks.findBacklogCandidates( + BacklogCandidateQuery( + ownerId: ownerId, + page: RepositoryPageRequest(cursor: cursor, limit: 200), + ), + ); + tasks.addAll(page.items); + cursor = page.nextCursor; + } while (cursor != null); + + return List.unmodifiable(tasks); + } + + /// Applies request filters using the existing Backlog view policy. + List _filterBacklogTasks({ + required List tasks, + required GetBacklogBoardRequest request, + required OwnerSettings settings, + }) { + var filtered = tasks; + for (final filter in request.filters) { + filtered = BacklogView( + tasks: filtered, + now: request.context.now, + stalenessSettings: settings.backlogStalenessSettings, + ).filter(filter); + } + return List.unmodifiable(filtered); + } + + /// Applies case-insensitive text search over board card text. + List _searchBoardItems({ + required List items, + required String? searchText, + }) { + final query = searchText?.trim().toLowerCase(); + if (query == null || query.isEmpty) { + return items; + } + return List.unmodifiable( + items.where((item) { + final haystack = [ + item.title, + if (item.subtitle != null) item.subtitle!, + item.bucketReason, + item.projectId, + item.projectName, + ...item.tags, + ].join(' ').toLowerCase(); + return haystack.contains(query); + }), + ); + } + + /// Applies board-specific filters after card bucket metadata has been resolved. + List _filterBoardItems({ + required List items, + required BacklogBoardFilterSelection filters, + }) { + if (filters.isEmpty) { + return items; + } + return List.unmodifiable( + items.where((item) { + return _matchesPriorityFilters(item, filters.priorityGroups) && + _matchesProjectFilters(item, filters.projectIds) && + _matchesDurationFilters( + item, + filters.durationBuckets, + filters.missingDurationOnly, + ) && + _matchesBucketFilters(item, filters.buckets); + }), + ); + } + + /// Whether [item] matches selected priority groups. + bool _matchesPriorityFilters( + BacklogBoardItemReadModel item, + Set priorityGroups, + ) { + if (priorityGroups.isEmpty) { + return true; + } + return priorityGroups.any((filter) { + return switch (filter) { + BacklogBoardPriorityFilter.high => + item.priority == PriorityLevel.high || + item.priority == PriorityLevel.veryHigh, + BacklogBoardPriorityFilter.medium => + item.priority == PriorityLevel.medium, + BacklogBoardPriorityFilter.low => item.priority == PriorityLevel.low || + item.priority == PriorityLevel.veryLow, + }; + }); + } + + /// Whether [item] matches selected project ids. + bool _matchesProjectFilters( + BacklogBoardItemReadModel item, + Set projectIds, + ) { + return projectIds.isEmpty || projectIds.contains(item.projectId); + } + + /// Whether [item] matches selected duration buckets and missing-duration state. + bool _matchesDurationFilters( + BacklogBoardItemReadModel item, + Set durationBuckets, + bool missingDurationOnly, + ) { + final duration = item.durationMinutes; + if (missingDurationOnly && duration == null) { + return true; + } + if (missingDurationOnly && duration != null && durationBuckets.isEmpty) { + return false; + } + if (durationBuckets.isEmpty) { + return !missingDurationOnly; + } + if (duration == null) { + return false; + } + return durationBuckets.any((filter) { + return switch (filter) { + BacklogBoardDurationFilter.zeroToThirty => duration <= 30, + BacklogBoardDurationFilter.thirtyToSixty => + duration > 30 && duration <= 60, + BacklogBoardDurationFilter.sixtyToOneTwenty => + duration > 60 && duration <= 120, + BacklogBoardDurationFilter.overOneTwenty => duration > 120, + }; + }); + } + + /// Whether [item] matches selected board buckets. + bool _matchesBucketFilters( + BacklogBoardItemReadModel item, + Set buckets, + ) { + return buckets.isEmpty || buckets.contains(item.bucket); + } + + /// Sorts board tasks using the requested sort and optional project grouping. + List _sortBoardTasks({ + required List tasks, + required GetBacklogBoardRequest request, + required OwnerSettings settings, + required Map projectById, + }) { + final sorted = BacklogView( + tasks: tasks, + now: request.context.now, + stalenessSettings: settings.backlogStalenessSettings, + ).sorted(request.sortKey); + if (request.groupMode == BacklogBoardGroupMode.none) { + return sorted; + } + + final indexed = sorted.asMap().entries.toList(growable: false); + indexed.sort((a, b) { + final groupComparison = _boardGroupComparison( + left: a.value, + right: b.value, + groupMode: request.groupMode, + projectById: projectById, + ); + if (groupComparison != 0) { + return groupComparison; + } + return a.key.compareTo(b.key); + }); + return List.unmodifiable(indexed.map((entry) => entry.value)); + } + + /// Compares two tasks for board grouping modes that preserve bucket columns. + int _boardGroupComparison({ + required Task left, + required Task right, + required BacklogBoardGroupMode groupMode, + required Map projectById, + }) { + return switch (groupMode) { + BacklogBoardGroupMode.none => 0, + BacklogBoardGroupMode.project => _projectNameFor( + left.projectId, + projectById, + ).compareTo( + _projectNameFor(right.projectId, projectById), + ), + BacklogBoardGroupMode.priority => + _priorityGroupRank(right.priority).compareTo( + _priorityGroupRank(left.priority), + ), + BacklogBoardGroupMode.duration => + _durationGroupRank(left.durationMinutes).compareTo( + _durationGroupRank(right.durationMinutes), + ), + }; + } + + /// Converts nullable priority to the board's coarse priority group rank. + int _priorityGroupRank(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryHigh || PriorityLevel.high => 2, + PriorityLevel.medium || null => 1, + PriorityLevel.low || PriorityLevel.veryLow => 0, + }; + } + + /// Converts nullable duration to the board's coarse duration group rank. + int _durationGroupRank(int? durationMinutes) { + if (durationMinutes == null) { + return 4; + } + if (durationMinutes <= 30) { + return 0; + } + if (durationMinutes <= 60) { + return 1; + } + if (durationMinutes <= 120) { + return 2; + } + return 3; + } + + /// Loads direct child task previews for [parentTasks]. + Future>> _loadChildrenByParentId({ + required ApplicationUnitOfWorkRepositories repositories, + required String ownerId, + required List parentTasks, + }) async { + final result = >{}; + for (final parent in parentTasks) { + final children = []; + String? cursor; + do { + final page = await repositories.tasks.findByParentTaskId( + ownerId: ownerId, + parentTaskId: parent.id, + page: RepositoryPageRequest(cursor: cursor, limit: 50), + ); + children.addAll(page.items); + cursor = page.nextCursor; + } while (cursor != null); + children.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + result[parent.id] = List.unmodifiable(children); + } + return Map>.unmodifiable(result); + } + + /// Maps one task into its board card read model. + BacklogBoardItemReadModel _boardItemForTask({ + required Task task, + required Map projectById, + required List childTasks, + }) { + final resolution = backlogBoardBucketPolicy.resolve( + task: task, + hasChildTasks: childTasks.isNotEmpty, + ); + return BacklogBoardItemReadModel( + taskId: task.id, + title: task.title, + subtitle: null, + projectId: task.projectId, + projectName: _projectNameFor(task.projectId, projectById), + projectColorToken: _projectColorTokenFor(task.projectId, projectById), + durationMinutes: task.durationMinutes, + priority: task.priority, + reward: task.reward, + difficulty: task.difficulty, + bucket: resolution.bucket, + bucketReason: resolution.reason, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + hasChildren: childTasks.isNotEmpty, + childPreview: [ + for (final child in childTasks.take(3)) + BacklogChildPreviewReadModel( + taskId: child.id, + title: child.title, + durationMinutes: child.durationMinutes, + isCompleted: child.status == TaskStatus.completed, + statusLabel: _taskStatusLabel(child.status), + ), + ], + tags: _readOnlyTagLabelsFor(task), + ); + } + + /// Builds the stable four-column board from [items]. + List _columnsForBoardItems( + List items, + ) { + return [ + for (final bucket in _boardBucketOrder) + BacklogBoardColumnReadModel( + bucket: bucket, + title: _bucketTitle(bucket), + subtitle: _bucketSubtitle(bucket), + accentToken: _bucketAccentToken(bucket), + items: [ + for (final item in items) + if (item.bucket == bucket) item, + ], + ), + ]; + } + + /// Builds summary counts from the same filtered board [items]. + BacklogBoardSummaryReadModel _summaryForBoardItems( + List items, + ) { + final totalEstimatedMinutes = items.fold( + 0, + (sum, item) => sum + (item.durationMinutes ?? 0), + ); + return BacklogBoardSummaryReadModel( + totalTaskCount: items.length, + totalEstimatedMinutes: totalEstimatedMinutes, + priorityDistribution: _priorityDistributionFor(items), + projectDistribution: _projectDistributionFor(items), + durationDistribution: _durationDistributionFor(items), + planningTip: _planningTipFor(items), + ); + } + + /// Builds project picker options for the board and drawer. + List _projectOptionsFor( + List projects, + ) { + return List.unmodifiable( + projects.map( + (project) => ProjectOptionReadModel( + projectId: project.id, + name: project.name, + colorToken: project.colorKey, + isArchived: project.isArchived, + ), + ), + ); + } + + /// Builds non-mutating suggested slots for [task]. + Future> _suggestedSlotsForTask({ + required ApplicationUnitOfWorkRepositories repositories, + required ApplicationOperationContext context, + required CivilDate localDate, + required Task task, + }) async { + final durationMinutes = task.durationMinutes; + if (durationMinutes == null) { + return const []; + } + final ownerId = context.ownerTimeZone.ownerId; + final settings = await _ownerSettingsOrDefault( + repositories, + context.ownerTimeZone, + ); + final window = _planningWindowFor( + date: localDate, + settings: settings.copyWith(timeZoneId: context.ownerTimeZone.timeZoneId), + ); + final lockedExpansion = expandLockedBlocksForDay( + blocks: (await repositories.lockedBlocks.findBlocksByOwner( + ownerId: ownerId, + )) + .items, + overrides: (await repositories.lockedBlocks.findOverridesForDate( + ownerId: ownerId, + date: localDate, + )) + .items, + date: localDate, + timeZoneId: context.ownerTimeZone.timeZoneId, + timeZoneResolver: timeZoneResolver, + resolutionOptions: resolutionOptions, + ); + final tasks = (await repositories.tasks.findForLocalDay( + ownerId: ownerId, + localDate: localDate, + window: window, + )) + .items; + final input = SchedulingInput( + tasks: tasks, + window: window, + lockedIntervals: lockedExpansion.schedulingIntervals, + ); + final duration = Duration(minutes: durationMinutes); + final windowStart = _previewStartFor(window, context.now); + final primary = const SchedulingEngine().findFirstOpenInterval( + windowStart: windowStart, + windowEnd: window.end, + duration: duration, + blocked: input.blockedIntervals, + ); + if (primary == null) { + return const []; + } + final localDateLabel = _localDateLabelFor(localDate, window, context.now); + final slots = [ + _suggestedSlotReadModel( + id: '${task.id}:primary', + interval: primary, + localDateLabel: localDateLabel, + durationMinutes: durationMinutes, + fitLabel: 'Great Fit', + fitSeverityToken: 'fit-great', + isPrimary: true, + ), + ]; + final secondary = const SchedulingEngine().findFirstOpenInterval( + windowStart: primary.end, + windowEnd: window.end, + duration: duration, + blocked: input.blockedIntervals, + ); + if (secondary != null && secondary.start != primary.start) { + slots.add( + _suggestedSlotReadModel( + id: '${task.id}:secondary', + interval: secondary, + localDateLabel: localDateLabel, + durationMinutes: durationMinutes, + fitLabel: 'Okay', + fitSeverityToken: 'fit-okay', + isPrimary: false, + ), + ); + } + return List.unmodifiable(slots); + } + + /// Resolves the owner-local planning window for [date]. + SchedulingWindow _planningWindowFor({ + required CivilDate date, + required OwnerSettings settings, + }) { + return SchedulingWindow( + start: _resolve( + date: date, + wallTime: settings.dayStart, + timeZoneId: settings.timeZoneId, + ), + end: _resolve( + date: date, + wallTime: settings.dayEnd, + timeZoneId: settings.timeZoneId, + ), + ); + } + + /// Resolves [wallTime] on [date] through the configured preview resolver. + DateTime _resolve({ + required CivilDate date, + required WallTime wallTime, + required String timeZoneId, + }) { + return timeZoneResolver + .resolve( + date: date, + wallTime: wallTime, + timeZoneId: timeZoneId, + options: resolutionOptions, + ) + .instant; + } + /// Load configured project defaults with learned suggestions kept separate. Future> getProjectDefaults( GetProjectDefaultsRequest request, @@ -116,6 +761,24 @@ class V1ApplicationManagementUseCases { ); } + /// Load the effective owner settings, falling back to defaults when none + /// have been persisted yet. + Future> getOwnerSettings( + GetOwnerSettingsRequest request, + ) { + return applicationStore.read( + action: (repositories) async { + final ownerId = request.context.ownerTimeZone.ownerId; + logger.debug(() => 'Loading owner settings query. ownerId=$ownerId'); + final settings = await _ownerSettingsOrDefault( + repositories, + request.context.ownerTimeZone, + ); + return ApplicationResult.success(settings); + }, + ); + } + /// Runs the `createProject` operation and completes after its asynchronous work finishes. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. Future> createProject({ @@ -605,6 +1268,337 @@ class V1ApplicationManagementUseCases { } } +/// Canonical board bucket order for the V1 Backlog board. +const _boardBucketOrder = [ + BacklogBoardBucket.doNext, + BacklogBoardBucket.needTimeBlock, + BacklogBoardBucket.breakUpFirst, + BacklogBoardBucket.notNow, +]; + +/// Resolves the display title for [bucket]. +String _bucketTitle(BacklogBoardBucket bucket) { + return switch (bucket) { + BacklogBoardBucket.doNext => 'Do Next', + BacklogBoardBucket.needTimeBlock => 'Need Time Block', + BacklogBoardBucket.breakUpFirst => 'Break Up First', + BacklogBoardBucket.notNow => 'Not Now', + }; +} + +/// Resolves the display subtitle for [bucket]. +String _bucketSubtitle(BacklogBoardBucket bucket) { + return switch (bucket) { + BacklogBoardBucket.doNext => 'Ready when you have a small opening.', + BacklogBoardBucket.needTimeBlock => 'Give these a protected block.', + BacklogBoardBucket.breakUpFirst => 'Split these before scheduling.', + BacklogBoardBucket.notNow => 'Keep these out of today pressure.', + }; +} + +/// Resolves the UI accent token for [bucket]. +String _bucketAccentToken(BacklogBoardBucket bucket) { + return switch (bucket) { + BacklogBoardBucket.doNext => 'backlog-do-next', + BacklogBoardBucket.needTimeBlock => 'backlog-time-block', + BacklogBoardBucket.breakUpFirst => 'backlog-break-up', + BacklogBoardBucket.notNow => 'backlog-not-now', + }; +} + +/// Resolves the display project name for [projectId]. +String _projectNameFor( + String projectId, + Map projectById, +) { + return projectById[projectId]?.name ?? _fallbackProjectName(projectId); +} + +/// Resolves the UI project color token for [projectId]. +String _projectColorTokenFor( + String projectId, + Map projectById, +) { + return projectById[projectId]?.colorKey ?? 'project-$projectId'; +} + +/// Builds a readable project fallback from a stable project id. +String _fallbackProjectName(String projectId) { + final words = projectId + .split(RegExp('[-_]')) + .where((word) => word.trim().isNotEmpty) + .toList(growable: false); + if (words.isEmpty) { + return projectId; + } + return words.map(_capitalized).join(' '); +} + +/// Capitalizes [value] for fallback display labels. +String _capitalized(String value) { + if (value.isEmpty) { + return value; + } + return value.substring(0, 1).toUpperCase() + value.substring(1); +} + +/// Resolves a compact task status label. +String _taskStatusLabel(TaskStatus status) { + return switch (status) { + TaskStatus.backlog => 'Backlog', + TaskStatus.planned => 'Planned', + TaskStatus.active => 'Active', + TaskStatus.completed => 'Completed', + TaskStatus.cancelled => 'Archived', + TaskStatus.missed => 'Missed', + TaskStatus.noLongerRelevant => 'Archived', + }; +} + +/// Builds read-only tag labels from existing narrow backlog flags. +List _readOnlyTagLabelsFor(Task task) { + final labels = [ + for (final tag in task.backlogTags) + switch (tag) { + BacklogTag.wishlist => 'someday', + }, + ]..sort(); + return List.unmodifiable(labels); +} + +/// Builds the priority distribution for the summary panel. +BacklogDistributionReadModel _priorityDistributionFor( + List items, +) { + final priorities = [ + PriorityLevel.veryHigh, + PriorityLevel.high, + PriorityLevel.medium, + PriorityLevel.low, + PriorityLevel.veryLow, + null, + ]; + final entries = []; + for (final priority in priorities) { + final count = items.where((item) => item.priority == priority).length; + if (count == 0) { + continue; + } + entries.add( + BacklogDistributionEntryReadModel( + id: priority?.name ?? 'unset', + label: _priorityLabel(priority), + count: count, + percentage: _percentage(count, items.length), + colorToken: priority == null ? null : 'priority-${priority.name}', + ), + ); + } + return BacklogDistributionReadModel(title: 'Priority', entries: entries); +} + +/// Builds the project distribution for the summary panel. +BacklogDistributionReadModel _projectDistributionFor( + List items, +) { + final projectIds = items.map((item) => item.projectId).toSet().toList() + ..sort((a, b) { + final left = items.firstWhere((item) => item.projectId == a).projectName; + final right = items.firstWhere((item) => item.projectId == b).projectName; + return left.compareTo(right); + }); + final entries = [ + for (final projectId in projectIds) + BacklogDistributionEntryReadModel( + id: projectId, + label: + items.firstWhere((item) => item.projectId == projectId).projectName, + count: items.where((item) => item.projectId == projectId).length, + percentage: _percentage( + items.where((item) => item.projectId == projectId).length, + items.length, + ), + colorToken: items + .firstWhere((item) => item.projectId == projectId) + .projectColorToken, + ), + ]; + return BacklogDistributionReadModel(title: 'Project', entries: entries); +} + +/// Builds the duration distribution for the summary panel. +BacklogDistributionReadModel _durationDistributionFor( + List items, +) { + final short = items.where((item) { + final duration = item.durationMinutes; + return duration != null && duration <= 30; + }).length; + final medium = items.where((item) { + final duration = item.durationMinutes; + return duration != null && duration > 30 && duration <= 60; + }).length; + final long = items.where((item) { + final duration = item.durationMinutes; + return duration != null && duration > 60 && duration <= 120; + }).length; + final veryLong = items.where((item) { + final duration = item.durationMinutes; + return duration != null && duration > 120; + }).length; + final missing = items.where((item) => item.durationMinutes == null).length; + final entries = [ + BacklogDistributionEntryReadModel( + id: '0-30', + label: '0-30', + count: short, + percentage: _percentage(short, items.length), + ), + BacklogDistributionEntryReadModel( + id: '30-60', + label: '30-60', + count: medium, + percentage: _percentage(medium, items.length), + ), + BacklogDistributionEntryReadModel( + id: '60-120', + label: '60-120', + count: long, + percentage: _percentage(long, items.length), + ), + BacklogDistributionEntryReadModel( + id: '120+', + label: '120+', + count: veryLong, + percentage: _percentage(veryLong, items.length), + ), + if (missing > 0) + BacklogDistributionEntryReadModel( + id: 'no-estimate', + label: 'No estimate', + count: missing, + percentage: _percentage(missing, items.length), + ), + ]; + return BacklogDistributionReadModel(title: 'Duration', entries: entries); +} + +/// Resolves a display label for [priority]. +String _priorityLabel(PriorityLevel? priority) { + return switch (priority) { + PriorityLevel.veryHigh => 'Very High', + PriorityLevel.high => 'High', + PriorityLevel.medium => 'Medium', + PriorityLevel.low => 'Low', + PriorityLevel.veryLow => 'Very Low', + null => 'Unset', + }; +} + +/// Calculates a distribution percentage. +double _percentage(int count, int total) { + if (total == 0) { + return 0; + } + return count / total; +} + +/// Resolves the summary planning tip from the current board shape. +String _planningTipFor(List items) { + if (items.isEmpty) { + return 'Backlog is clear.'; + } + if (items.any((item) => item.bucket == BacklogBoardBucket.breakUpFirst)) { + return 'Break up one large task before adding more to today.'; + } + if (items.any((item) => item.bucket == BacklogBoardBucket.needTimeBlock)) { + return 'Protect one focused block before choosing smaller tasks.'; + } + return 'Pick one Do Next task that fits your current energy.'; +} + +/// Picks the preview search start within [window]. +DateTime _previewStartFor(SchedulingWindow window, DateTime now) { + if (now.isAfter(window.start) && now.isBefore(window.end)) { + return now; + } + if (now.isAfter(window.end) || now.isAtSameMomentAs(window.end)) { + return window.end; + } + return window.start; +} + +/// Builds the display label for a suggested slot date. +String _localDateLabelFor( + CivilDate localDate, + SchedulingWindow window, + DateTime now, +) { + if ((now.isAfter(window.start) || now.isAtSameMomentAs(window.start)) && + now.isBefore(window.end)) { + return 'Today'; + } + return localDate.toIsoString(); +} + +/// Builds one suggested slot read model from [interval]. +SuggestedSlotReadModel _suggestedSlotReadModel({ + required String id, + required TimeInterval interval, + required String localDateLabel, + required int durationMinutes, + required String fitLabel, + required String fitSeverityToken, + required bool isPrimary, +}) { + return SuggestedSlotReadModel( + id: id, + startUtc: interval.start.toUtc(), + endUtc: interval.end.toUtc(), + localDateLabel: localDateLabel, + startText: _clockText(interval.start), + endText: _clockText(interval.end), + durationMinutes: durationMinutes, + fitLabel: fitLabel, + fitSeverityToken: fitSeverityToken, + isPrimary: isPrimary, + ); +} + +/// Formats [instant] as compact 12-hour clock text. +String _clockText(DateTime instant) { + final hour12 = instant.hour % 12 == 0 ? 12 : instant.hour % 12; + final minute = instant.minute.toString().padLeft(2, '0'); + final period = instant.hour < 12 ? 'AM' : 'PM'; + return '$hour12:$minute $period'; +} + +/// Returns a display reason when suggested slots are unavailable. +String? _suggestedSlotUnavailableReasonFor( + Task task, + List suggestions, +) { + if (task.durationMinutes == null) { + return 'Add an estimate to see suggested slots.'; + } + if (suggestions.isEmpty) { + return 'No open slot fits this estimate today.'; + } + return null; +} + +/// Builds the drawer added-age label for [task]. +String _addedLabelFor(Task task, DateTime now) { + final days = now.difference(task.backlogFreshnessAnchorAt).inDays; + if (days <= 0) { + return 'Added today'; + } + if (days == 1) { + return 'Added yesterday'; + } + return 'Added $days days ago'; +} + /// Top-level helper that performs the `_ownerSettingsOrDefault` operation for this file. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail. Future _ownerSettingsOrDefault( diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart index 12628cc..1e293cd 100644 --- a/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/enums/persistence_enum_mapping.dart @@ -249,6 +249,7 @@ abstract final class PersistenceEnumMapping { SchedulingIssueCode.missingDuration => 'missing_duration', SchedulingIssueCode.missingScheduledSlot => 'missing_scheduled_slot', SchedulingIssueCode.nonPositiveDuration => 'non_positive_duration', + SchedulingIssueCode.durationMismatch => 'duration_mismatch', SchedulingIssueCode.noAvailableSlot => 'no_available_slot', SchedulingIssueCode.unfinishedTasksCouldNotFit => 'unfinished_tasks_could_not_fit', diff --git a/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart index 64794ec..e43e5a6 100644 --- a/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart +++ b/packages/scheduler_core/lib/src/persistence/document_mapping/projects/project_statistics_document_extension.dart @@ -147,6 +147,7 @@ const _schedulingIssueCodeByCode = { 'missing_duration': SchedulingIssueCode.missingDuration, 'missing_scheduled_slot': SchedulingIssueCode.missingScheduledSlot, 'non_positive_duration': SchedulingIssueCode.nonPositiveDuration, + 'duration_mismatch': SchedulingIssueCode.durationMismatch, 'no_available_slot': SchedulingIssueCode.noAvailableSlot, 'unfinished_tasks_could_not_fit': SchedulingIssueCode.unfinishedTasksCouldNotFit, diff --git a/packages/scheduler_core/lib/src/scheduling/backlog.dart b/packages/scheduler_core/lib/src/scheduling/backlog.dart index 738cf2a..4ed9359 100644 --- a/packages/scheduler_core/lib/src/scheduling/backlog.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog.dart @@ -12,6 +12,9 @@ library; // scheduler so the engine can focus on moving tasks through time. import '../domain/models.dart'; +part 'backlog/board/backlog_board_bucket.dart'; +part 'backlog/board/backlog_board_bucket_policy.dart'; +part 'backlog/board/backlog_board_bucket_resolution.dart'; part 'backlog/queries/backlog_filter.dart'; part 'backlog/queries/backlog_sort_key.dart'; part 'backlog/staleness/backlog_staleness_marker.dart'; diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket.dart b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket.dart new file mode 100644 index 0000000..ee9b6da --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../backlog.dart'; + +/// Stable column identifiers for the V1 Backlog board. +enum BacklogBoardBucket { + /// Schedule-ready tasks that are good candidates for the next open slot. + doNext, + + /// Tasks that need a deliberate focused block before they should be scheduled. + needTimeBlock, + + /// Tasks whose size or effort suggests planning smaller child tasks first. + breakUpFirst, + + /// Low-pressure or intentionally deferred tasks that should stay out of the main queue. + notNow, +} diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_policy.dart b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_policy.dart new file mode 100644 index 0000000..9ddc6e9 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_policy.dart @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../backlog.dart'; + +/// Deterministic V1 policy that assigns backlog tasks to board columns. +class BacklogBoardBucketPolicy { + /// Creates the default V1 backlog board bucket policy. + const BacklogBoardBucketPolicy(); + + /// Resolves the board bucket for [task]. + /// + /// [hasChildTasks] is supplied by the application read layer after it has + /// looked up child task metadata. The policy stays pure and does not read + /// repositories directly. + BacklogBoardBucketResolution resolve({ + required Task task, + bool hasChildTasks = false, + }) { + if (task.backlogTags.contains(BacklogTag.wishlist)) { + return const BacklogBoardBucketResolution( + bucket: BacklogBoardBucket.notNow, + reason: 'Saved for someday.', + ); + } + if (_isLowPriority(task.priority) && + !_isOtherwiseUrgent(task: task, hasChildTasks: hasChildTasks)) { + return const BacklogBoardBucketResolution( + bucket: BacklogBoardBucket.notNow, + reason: 'Low pressure and not urgent.', + ); + } + if (task.durationMinutes == null && + !_isReadyToSchedule(task: task, hasChildTasks: hasChildTasks)) { + return const BacklogBoardBucketResolution( + bucket: BacklogBoardBucket.notNow, + reason: 'Needs an estimate before scheduling.', + ); + } + if ((task.durationMinutes ?? 0) >= 90) { + return const BacklogBoardBucketResolution( + bucket: BacklogBoardBucket.breakUpFirst, + reason: 'Large enough to break into smaller steps.', + ); + } + if (_isHard(task.difficulty)) { + return const BacklogBoardBucketResolution( + bucket: BacklogBoardBucket.breakUpFirst, + reason: 'High effort; smaller steps may help.', + ); + } + if (hasChildTasks) { + return const BacklogBoardBucketResolution( + bucket: BacklogBoardBucket.breakUpFirst, + reason: 'Already has child-task planning.', + ); + } + if ((task.durationMinutes ?? 0) >= 45) { + return const BacklogBoardBucketResolution( + bucket: BacklogBoardBucket.needTimeBlock, + reason: 'Needs a focused time block.', + ); + } + if (_isHighPriority(task.priority)) { + return const BacklogBoardBucketResolution( + bucket: BacklogBoardBucket.needTimeBlock, + reason: 'High priority work.', + ); + } + + return const BacklogBoardBucketResolution( + bucket: BacklogBoardBucket.doNext, + reason: 'Ready for the next available slot.', + ); + } + + /// Whether [priority] should stay out of the main queue when no urgent signal exists. + bool _isLowPriority(PriorityLevel? priority) { + return priority == PriorityLevel.veryLow || priority == PriorityLevel.low; + } + + /// Whether [priority] should be treated as important enough to plan deliberately. + bool _isHighPriority(PriorityLevel? priority) { + return priority == PriorityLevel.high || priority == PriorityLevel.veryHigh; + } + + /// Whether [difficulty] indicates the task should be broken down first. + bool _isHard(DifficultyLevel difficulty) { + return difficulty == DifficultyLevel.hard || + difficulty == DifficultyLevel.veryHard; + } + + /// Whether [task] has any signal that should override low-pressure handling. + bool _isOtherwiseUrgent({ + required Task task, + required bool hasChildTasks, + }) { + return _isHighPriority(task.priority) || + (task.durationMinutes ?? 0) >= 45 || + _isHard(task.difficulty) || + hasChildTasks; + } + + /// Whether [task] has enough scheduling information for board planning. + bool _isReadyToSchedule({ + required Task task, + required bool hasChildTasks, + }) { + return task.durationMinutes != null || + _isHighPriority(task.priority) || + _isHard(task.difficulty) || + hasChildTasks; + } +} diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_resolution.dart b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_resolution.dart new file mode 100644 index 0000000..7a6e6c9 --- /dev/null +++ b/packages/scheduler_core/lib/src/scheduling/backlog/board/backlog_board_bucket_resolution.dart @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 FocusFlow contributors +// SPDX-License-Identifier: AGPL-3.0-only + +part of '../../backlog.dart'; + +/// Result returned by [BacklogBoardBucketPolicy] for one backlog task. +class BacklogBoardBucketResolution { + /// Creates a bucket resolution with the selected [bucket] and user-facing [reason]. + const BacklogBoardBucketResolution({ + required this.bucket, + required this.reason, + }); + + /// Board bucket selected for the task. + final BacklogBoardBucket bucket; + + /// Short user-facing explanation for why the task belongs in [bucket]. + final String reason; +} diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart b/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart index 7fbdd39..c1997e4 100644 --- a/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog/queries/backlog_sort_key.dart @@ -9,6 +9,9 @@ part of '../../backlog.dart'; /// example, `rewardVsEffort` maps to a simple derived score instead of a stored /// field. Persistence can later index the underlying fields if needed. enum BacklogSortKey { + /// Preserve the source/read-model order supplied by persistence. + custom, + /// Highest priority first. priority, @@ -24,4 +27,7 @@ enum BacklogSortKey { /// Most frequently pushed tasks first. timesPushed, + + /// Shortest estimated duration first, with missing estimates last. + duration, } diff --git a/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart index defde6f..868c330 100644 --- a/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart +++ b/packages/scheduler_core/lib/src/scheduling/backlog/views/backlog_view.dart @@ -113,6 +113,7 @@ class BacklogView { /// appear earlier, while older age uses the earliest [Task.createdAt] first. int _compareTasks(Task a, Task b, BacklogSortKey sortKey) { return switch (sortKey) { + BacklogSortKey.custom => 0, BacklogSortKey.priority => _priorityRank(b.priority).compareTo(_priorityRank(a.priority)), BacklogSortKey.rewardVsEffort => @@ -121,6 +122,9 @@ class BacklogView { a.backlogFreshnessAnchorAt.compareTo(b.backlogFreshnessAnchorAt), BacklogSortKey.project => a.projectId.compareTo(b.projectId), BacklogSortKey.timesPushed => _timesPushed(b).compareTo(_timesPushed(a)), + BacklogSortKey.duration => _durationRank(a.durationMinutes).compareTo( + _durationRank(b.durationMinutes), + ), }; } } @@ -177,3 +181,20 @@ int _rewardVsEffortScore(Task task) { int _timesPushed(Task task) { return task.stats.manuallyPushedCount + task.stats.autoPushedCount; } + +/// Convert nullable duration to a stable sort rank. +int _durationRank(int? durationMinutes) { + if (durationMinutes == null) { + return 4; + } + if (durationMinutes <= 30) { + return 0; + } + if (durationMinutes <= 60) { + return 1; + } + if (durationMinutes <= 120) { + return 2; + } + return 3; +} diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart index 021c4d6..aafcff9 100644 --- a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/notices/scheduling_issue_code.dart @@ -25,6 +25,10 @@ enum SchedulingIssueCode { /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. nonPositiveDuration, + /// Selects the `durationMismatch` option from this enum. + /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. + durationMismatch, + /// Selects the `noAvailableSlot` option from this enum. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning. noAvailableSlot, diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart index fa3b4e8..ef14bec 100644 --- a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/codes/operations/scheduling_operation_code.dart @@ -11,6 +11,9 @@ enum SchedulingOperationCode { /// Insert a backlog task into the next available slot. insertBacklogTaskIntoNextAvailableSlot, + /// Insert a backlog task into an explicitly requested open slot. + insertBacklogTaskAtRequestedSlot, + /// Push a flexible task later in the current window. pushFlexibleTaskToNextAvailableSlot, diff --git a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart index a420756..b643cad 100644 --- a/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart +++ b/packages/scheduler_core/lib/src/scheduling/scheduling_engine/engine/scheduling_engine.dart @@ -11,6 +11,7 @@ part of '../../scheduling_engine.dart'; /// /// Current V1 responsibilities: /// - insert backlog tasks into the earliest available flexible slot; +/// - insert backlog tasks into an explicitly selected open slot; /// - push flexible tasks later today; /// - move flexible tasks to tomorrow's queue; /// - roll unfinished flexible tasks into a new planning window; @@ -137,6 +138,163 @@ class SchedulingEngine { ); } + /// Insert a backlog task into a specific open interval. + /// + /// The requested interval must be fully inside [input.window], match the + /// backlog task's duration, and avoid fixed blocked time. Existing planned + /// flexible tasks that overlap or follow the requested slot may move later, + /// preserving their relative order. + SchedulingResult insertBacklogTaskAtRequestedSlot({ + required SchedulingInput input, + required String taskId, + required DateTime requestedStart, + required DateTime requestedEnd, + DateTime? updatedAt, + }) { + logger.debug(() => 'Scheduling backlog task into requested slot. ' + 'taskId=$taskId taskCount=${input.tasks.length} ' + 'requestedStart=${requestedStart.toIso8601String()} ' + 'requestedEnd=${requestedEnd.toIso8601String()}'); + final task = _taskById(input.tasks, taskId); + if (task == null) { + logger.warn(() => + 'Requested-slot backlog scheduling failed: task not found. taskId=$taskId'); + return _unchangedResult( + input, + SchedulingNotice( + 'Task was not found.', + type: SchedulingNoticeType.noFit, + taskId: taskId, + issueCode: SchedulingIssueCode.taskNotFound, + ), + operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot, + outcomeCode: SchedulingOutcomeCode.notFound, + ); + } + + if (!task.isFlexible || !task.isBacklog) { + logger.warn(() => + 'Requested-slot backlog scheduling failed: invalid task state. ' + 'taskId=${task.id} taskType=${task.type.name} taskStatus=${task.status.name}'); + return _unchangedResult( + input, + SchedulingNotice( + 'Only backlog flexible tasks can be inserted.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.invalidTaskState, + ), + operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final taskDuration = _durationFromMinutes(task.durationMinutes); + if (taskDuration == null) { + logger.warn(() => + 'Requested-slot backlog scheduling failed: missing positive duration. ' + 'taskId=${task.id} durationMinutes=${task.durationMinutes}'); + return _unchangedResult( + input, + SchedulingNotice( + 'Task needs a positive duration before scheduling.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.missingDuration, + ), + operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + if (!requestedStart.isBefore(requestedEnd)) { + logger.warn(() => + 'Requested-slot backlog scheduling failed: non-positive requested interval. ' + 'taskId=${task.id}'); + return _unchangedResult( + input, + SchedulingNotice( + 'Requested slot must have a positive duration.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.nonPositiveDuration, + ), + operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + final requestedInterval = TimeInterval( + start: requestedStart, + end: requestedEnd, + label: task.id, + ); + if (requestedInterval.duration != taskDuration) { + logger.warn(() => + 'Requested-slot backlog scheduling failed: duration mismatch. ' + 'taskId=${task.id} taskDurationMinutes=${taskDuration.inMinutes} ' + 'requestedDurationMinutes=${requestedInterval.duration.inMinutes}'); + return _unchangedResult( + input, + SchedulingNotice( + 'Requested slot no longer matches the task estimate.', + type: SchedulingNoticeType.noFit, + taskId: task.id, + issueCode: SchedulingIssueCode.durationMismatch, + ), + operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot, + outcomeCode: SchedulingOutcomeCode.invalidState, + ); + } + + if (!input.window.contains(requestedInterval)) { + logger.warn(() => + 'Requested-slot backlog scheduling failed: slot outside window. ' + 'taskId=${task.id}'); + return _unchangedResult( + input, + SchedulingNotice( + 'Requested slot is outside the planning window.', + type: SchedulingNoticeType.overflow, + taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, + ), + operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot, + outcomeCode: SchedulingOutcomeCode.noSlot, + ); + } + + final placement = _planBacklogInsertionAtRequestedSlot( + input: input, + task: task, + requestedInterval: requestedInterval, + ); + if (placement == null) { + logger.warn(() => + 'Requested-slot backlog scheduling failed: slot blocked or queue overflowed. ' + 'taskId=${task.id}'); + return _unchangedResult( + input, + SchedulingNotice( + 'Requested slot is no longer open.', + type: SchedulingNoticeType.overflow, + taskId: task.id, + issueCode: SchedulingIssueCode.noAvailableSlot, + ), + operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot, + outcomeCode: SchedulingOutcomeCode.noSlot, + ); + } + + return _applyPlacement( + input: input, + insertedTask: task, + placement: placement, + updatedAt: updatedAt ?? clock.now(), + operationCode: SchedulingOperationCode.insertBacklogTaskAtRequestedSlot, + ); + } + /// Push a planned flexible task to the next available slot today. /// /// The selected task moves after its current slot. Planned flexible tasks @@ -803,6 +961,88 @@ _BacklogInsertionPlan? _planBacklogInsertion({ return _BacklogInsertionPlan(placements: placements); } +/// Plan a backlog insertion that must start at [requestedInterval]. +/// +/// Flexible tasks ending before the requested interval remain fixed. Flexible +/// tasks that overlap or follow the requested interval join the queue after the +/// inserted task and may move later to preserve an exact selected slot. +_BacklogInsertionPlan? _planBacklogInsertionAtRequestedSlot({ + required SchedulingInput input, + required Task task, + required TimeInterval requestedInterval, +}) { + final fixedBlocks = [ + ...input.blockedIntervals, + ]; + final queue = <_PlacementItem>[]; + + final scheduledFlexibleTasks = input.movablePlannedFlexibleTasks + .where((flexibleTask) => flexibleTask.id != task.id) + .toList(growable: false) + ..sort((a, b) { + final aStart = a.scheduledStart ?? input.window.end; + final bStart = b.scheduledStart ?? input.window.end; + + return aStart.compareTo(bStart); + }); + + for (final flexibleTask in scheduledFlexibleTasks) { + final interval = _scheduledIntervalFor(flexibleTask); + if (interval == null) { + continue; + } + + final startsBeforeWindow = interval.start.isBefore(input.window.start); + final startsAfterWindow = interval.start.isAfter(input.window.end) || + interval.start.isAtSameMomentAs(input.window.end); + final endsBeforeRequested = + interval.end.isBefore(requestedInterval.start) || + interval.end.isAtSameMomentAs(requestedInterval.start); + + if (startsBeforeWindow || startsAfterWindow || endsBeforeRequested) { + fixedBlocks.add(interval); + continue; + } + + queue.add( + _PlacementItem( + task: flexibleTask, + duration: interval.duration, + earliestStart: interval.start, + ), + ); + } + + fixedBlocks.sort((a, b) => a.start.compareTo(b.start)); + if (fixedBlocks.any((interval) => interval.overlaps(requestedInterval))) { + return null; + } + + var cursor = requestedInterval.end; + final placements = { + task.id: requestedInterval, + }; + + for (final item in queue) { + final earliestStart = _laterOf(cursor, item.earliestStart); + final interval = _firstOpenIntervalFrom( + earliestStart: earliestStart, + windowEnd: input.window.end, + duration: item.duration, + blocked: fixedBlocks, + ); + + if (interval == null) { + return null; + } + + placements[item.task.id] = interval; + cursor = interval.end; + } + + return _BacklogInsertionPlan(placements: placements); +} + /// Plan the "push later today" behavior for one flexible task. /// /// Items before the pushed task's current end are fixed. The pushed task starts @@ -996,6 +1236,8 @@ SchedulingResult _applyPlacement({ required Task insertedTask, required _BacklogInsertionPlan placement, required DateTime updatedAt, + SchedulingOperationCode operationCode = + SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, }) { final changes = []; final notices = []; @@ -1028,8 +1270,7 @@ SchedulingResult _applyPlacement({ final updatedTask = _applySchedulingActivity( originalTask: task, updatedTask: placedTask, - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + operationCode: operationCode, activityCode: isInsertedTask ? TaskActivityCode.restoredFromBacklog : TaskActivityCode.automaticallyPushed, @@ -1075,8 +1316,7 @@ SchedulingResult _applyPlacement({ 'noticeCount=${notices.length}'); return SchedulingResult( tasks: List.unmodifiable(updatedTasks), - operationCode: - SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot, + operationCode: operationCode, outcomeCode: SchedulingOutcomeCode.success, notices: List.unmodifiable(notices), changes: List.unmodifiable(changes), diff --git a/packages/scheduler_core/test/application_commands_test.dart b/packages/scheduler_core/test/application_commands_test.dart index 60bae32..95cd4e6 100644 --- a/packages/scheduler_core/test/application_commands_test.dart +++ b/packages/scheduler_core/test/application_commands_test.dart @@ -113,6 +113,97 @@ void main() { ]); }); + test('schedules backlog item at suggested slot and persists movement', + () async { + final backlog = task( + id: 'suggested', + title: 'Suggested', + type: TaskType.flexible, + status: TaskStatus.backlog, + createdAt: createdAt, + durationMinutes: 30, + ); + final planned = task( + id: 'planned', + title: 'Planned', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final store = storeWithSettings(tasks: [backlog, planned]); + + final result = await commands(store).scheduleBacklogItemAtSuggestedSlot( + context: appContext('schedule-suggested', DateTime.utc(2026, 6, 25, 8)), + localDate: day, + taskId: backlog.id, + scheduledStart: DateTime.utc(2026, 6, 25, 9), + scheduledEnd: DateTime.utc(2026, 6, 25, 9, 30), + expectedUpdatedAt: backlog.updatedAt, + ); + + expect(result.isSuccess, isTrue); + expect( + result.requireValue.schedulingResult!.operationCode, + SchedulingOperationCode.insertBacklogTaskAtRequestedSlot, + ); + expect( + taskById(store.currentTasks, 'suggested').scheduledStart, + DateTime.utc(2026, 6, 25, 9), + ); + expect( + taskById(store.currentTasks, 'suggested').scheduledEnd, + DateTime.utc(2026, 6, 25, 9, 30), + ); + expect( + taskById(store.currentTasks, 'planned').scheduledStart, + DateTime.utc(2026, 6, 25, 9, 30), + ); + expect(store.currentTaskActivities.map((activity) => activity.code), [ + TaskActivityCode.restoredFromBacklog, + TaskActivityCode.automaticallyPushed, + ]); + }); + + test('suggested slot command rejects a blocked interval', () async { + final backlog = task( + id: 'blocked-suggested', + title: 'Blocked suggested', + type: TaskType.flexible, + status: TaskStatus.backlog, + createdAt: createdAt, + durationMinutes: 30, + ); + final requiredTask = task( + id: 'appointment', + title: 'Appointment', + type: TaskType.inflexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 10), + end: DateTime.utc(2026, 6, 25, 10, 30), + ); + final store = storeWithSettings(tasks: [backlog, requiredTask]); + + final result = await commands(store).scheduleBacklogItemAtSuggestedSlot( + context: appContext( + 'schedule-blocked-suggested', + DateTime.utc(2026, 6, 25, 8), + ), + localDate: day, + taskId: backlog.id, + scheduledStart: DateTime.utc(2026, 6, 25, 10), + scheduledEnd: DateTime.utc(2026, 6, 25, 10, 30), + expectedUpdatedAt: backlog.updatedAt, + ); + + expect(result.failure!.code, ApplicationFailureCode.noSlot); + expect(taskById(store.currentTasks, backlog.id), backlog); + expect(store.currentTaskActivities, isEmpty); + expect(store.currentOperations, isEmpty); + }); + test('schedules backlog item after duration is supplied', () async { final backlog = Task.quickCapture( id: 'backlog', @@ -316,6 +407,94 @@ void main() { ); }); + test('push backlog task to someday adds wishlist tag without scheduling', + () async { + final backlog = task( + id: 'someday', + title: 'Someday', + type: TaskType.flexible, + status: TaskStatus.backlog, + createdAt: createdAt, + durationMinutes: 25, + ); + final store = storeWithSettings(tasks: [backlog]); + + final result = await commands(store).pushBacklogTaskToSomeday( + context: appContext('push-someday', backlog.updatedAt), + taskId: backlog.id, + expectedUpdatedAt: backlog.updatedAt, + ); + + expect(result.isSuccess, isTrue); + final updated = taskById(store.currentTasks, backlog.id); + expect(updated.status, TaskStatus.backlog); + expect(updated.backlogTags, contains(BacklogTag.wishlist)); + expect(updated.scheduledStart, isNull); + expect(updated.updatedAt, backlog.updatedAt); + expect(result.requireValue.changedTasks, [updated]); + expect(store.currentOperations.single.operationId, 'push-someday'); + }); + + test('archive backlog task hides it without deleting persistence record', + () async { + final backlog = task( + id: 'archive-backlog', + title: 'Archive backlog', + type: TaskType.flexible, + status: TaskStatus.backlog, + createdAt: createdAt, + durationMinutes: 25, + ); + final store = storeWithSettings(tasks: [backlog]); + + final result = await commands(store).archiveBacklogTask( + context: appContext('archive-backlog', DateTime.utc(2026, 6, 25, 8)), + taskId: backlog.id, + expectedUpdatedAt: backlog.updatedAt, + ); + + expect(result.isSuccess, isTrue); + expect(store.currentTasks, hasLength(1)); + final archived = taskById(store.currentTasks, backlog.id); + expect(archived.status, TaskStatus.noLongerRelevant); + expect(archived.scheduledStart, isNull); + expect(archived.updatedAt, DateTime.utc(2026, 6, 25, 8)); + expect(result.requireValue.changedTasks, [archived]); + expect(store.currentOperations.single.operationId, 'archive-backlog'); + }); + + test('backlog-only commands reject planned tasks', () async { + final planned = task( + id: 'planned-only', + title: 'Planned only', + type: TaskType.flexible, + status: TaskStatus.planned, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + ); + final store = storeWithSettings(tasks: [planned]); + final useCases = commands(store); + + final someday = await useCases.pushBacklogTaskToSomeday( + context: appContext('planned-someday', DateTime.utc(2026, 6, 25, 8)), + taskId: planned.id, + expectedUpdatedAt: planned.updatedAt, + ); + final archive = await useCases.archiveBacklogTask( + context: appContext('planned-archive', DateTime.utc(2026, 6, 25, 8)), + taskId: planned.id, + expectedUpdatedAt: planned.updatedAt, + ); + + expect(someday.failure!.code, ApplicationFailureCode.conflict); + expect(someday.failure!.detailCode, 'notBacklogTask'); + expect(archive.failure!.code, ApplicationFailureCode.conflict); + expect(archive.failure!.detailCode, 'notBacklogTask'); + expect(taskById(store.currentTasks, planned.id), planned); + expect(store.currentOperations, isEmpty); + }); + test('push next moves an overdue task after the command time', () async { final overdue = task( id: 'overdue', diff --git a/packages/scheduler_core/test/application_management_test.dart b/packages/scheduler_core/test/application_management_test.dart index 92750d8..427fa61 100644 --- a/packages/scheduler_core/test/application_management_test.dart +++ b/packages/scheduler_core/test/application_management_test.dart @@ -70,6 +70,636 @@ void main() { ); }); + test('backlog board read models snapshot collection inputs', () { + final childPreviews = [ + const BacklogChildPreviewReadModel( + taskId: 'child-1', + title: 'Gather statements', + isCompleted: false, + durationMinutes: 15, + statusLabel: 'Backlog', + ), + ]; + final itemTags = ['finance']; + final item = BacklogBoardItemReadModel( + taskId: 'review-budget', + title: 'Review Q3 budget', + subtitle: 'Check the draft before planning.', + projectId: 'work', + projectName: 'Work', + projectColorToken: 'project-work', + durationMinutes: 60, + priority: PriorityLevel.high, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + bucket: BacklogBoardBucket.needTimeBlock, + bucketReason: 'Needs a focused time block.', + createdAt: now, + updatedAt: now, + childPreview: childPreviews, + tags: itemTags, + ); + + childPreviews.clear(); + itemTags.add('late'); + + expect(item.childPreview, hasLength(1)); + expect(item.tags, ['finance']); + expect(() => item.tags.add('new'), throwsUnsupportedError); + + final items = [item]; + final column = BacklogBoardColumnReadModel( + bucket: BacklogBoardBucket.needTimeBlock, + title: 'Need Time Block', + subtitle: 'Plan deliberately.', + accentToken: 'backlog-time-block', + items: items, + ); + items.clear(); + + expect(column.count, 1); + expect(column.items, [item]); + expect(() => column.items.clear(), throwsUnsupportedError); + + final entries = [ + const BacklogDistributionEntryReadModel( + id: 'high', + label: 'High', + count: 2, + percentage: 1, + colorToken: 'priority-high', + ), + ]; + final priorityDistribution = BacklogDistributionReadModel( + title: 'Priority', + entries: entries, + ); + entries.clear(); + + expect(priorityDistribution.totalCount, 2); + expect(priorityDistribution.entries, hasLength(1)); + expect( + () => priorityDistribution.entries.clear(), + throwsUnsupportedError, + ); + + final projects = [ + const ProjectOptionReadModel( + projectId: 'work', + name: 'Work', + colorToken: 'project-work', + ), + ]; + final summary = BacklogBoardSummaryReadModel( + totalTaskCount: 1, + totalEstimatedMinutes: 60, + priorityDistribution: priorityDistribution, + projectDistribution: BacklogDistributionReadModel( + title: 'Project', + entries: const [ + BacklogDistributionEntryReadModel( + id: 'work', + label: 'Work', + count: 1, + percentage: 1, + colorToken: 'project-work', + ), + ], + ), + durationDistribution: BacklogDistributionReadModel( + title: 'Duration', + entries: const [ + BacklogDistributionEntryReadModel( + id: '30-60', + label: '30-60', + count: 1, + percentage: 1, + ), + ], + ), + planningTip: 'Pick one focused task before adding more.', + ); + final result = BacklogBoardQueryResult( + ownerId: 'owner-1', + localDate: CivilDate(2026, 6, 25), + summary: summary, + columns: [column], + projectOptions: projects, + ); + projects.clear(); + + expect(result.columns, [column]); + expect(result.projectOptions, hasLength(1)); + expect(() => result.projectOptions.clear(), throwsUnsupportedError); + + final detail = BacklogTaskDetailReadModel( + item: item, + addedLabel: 'Added today', + tags: const ['finance'], + projectOptions: result.projectOptions, + suggestedSlots: [ + SuggestedSlotReadModel( + id: 'today-1', + startUtc: DateTime.utc(2026, 6, 25, 14), + endUtc: DateTime.utc(2026, 6, 25, 15), + localDateLabel: 'Today', + startText: '2:00 PM', + endText: '3:00 PM', + durationMinutes: 60, + fitLabel: 'Great Fit', + fitSeverityToken: 'fit-great', + isPrimary: true, + ), + ], + notes: 'Review budget categories before scheduling.', + ); + + expect(detail.tags, ['finance']); + expect(detail.suggestedSlots.single.fitLabel, 'Great Fit'); + expect(() => detail.suggestedSlots.clear(), throwsUnsupportedError); + }); + + test('returns empty backlog board with stable columns', () async { + final store = InMemoryApplicationUnitOfWork(); + final result = await V1ApplicationManagementUseCases( + applicationStore: store, + ).getBacklogBoard( + GetBacklogBoardRequest( + context: appContext(operationId: 'empty-board', now: now), + localDate: CivilDate(2026, 6, 25), + ), + ); + + final board = result.requireValue; + + expect(board.ownerId, 'owner-1'); + expect(board.columns.map((column) => column.bucket), [ + BacklogBoardBucket.doNext, + BacklogBoardBucket.needTimeBlock, + BacklogBoardBucket.breakUpFirst, + BacklogBoardBucket.notNow, + ]); + expect(board.columns.map((column) => column.count), [0, 0, 0, 0]); + expect( + board.columns.every((column) => column.items.isEmpty), + isTrue, + ); + expect(board.summary.totalTaskCount, 0); + expect(board.summary.totalEstimatedMinutes, 0); + expect(board.summary.projectDistribution.entries, isEmpty); + }); + + test('groups backlog board tasks into buckets and matching summaries', + () async { + final tasks = [ + backlogTask( + id: 'quick', + title: 'Reply to 3 emails', + projectId: 'home', + createdAt: now.subtract(const Duration(days: 1)), + durationMinutes: 20, + priority: PriorityLevel.medium, + reward: RewardLevel.high, + difficulty: DifficultyLevel.easy, + ), + backlogTask( + id: 'block', + title: 'Review Q3 budget', + projectId: 'work', + createdAt: now.subtract(const Duration(days: 2)), + durationMinutes: 60, + priority: PriorityLevel.high, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + ), + backlogTask( + id: 'break', + title: 'Plan next week', + projectId: 'work', + createdAt: now.subtract(const Duration(days: 3)), + durationMinutes: 120, + priority: PriorityLevel.medium, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + ), + backlogTask( + id: 'later', + title: 'Learn watercolor', + projectId: 'personal', + createdAt: now.subtract(const Duration(days: 4)), + durationMinutes: 15, + priority: PriorityLevel.low, + backlogTags: const {BacklogTag.wishlist}, + ), + backlogTask( + id: 'planned', + title: 'Already planned', + projectId: 'home', + createdAt: now, + status: TaskStatus.planned, + scheduledStart: DateTime.utc(2026, 6, 25, 15), + scheduledEnd: DateTime.utc(2026, 6, 25, 15, 30), + ), + ]; + final store = InMemoryApplicationUnitOfWork( + initialProjects: [ + ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'), + ProjectProfile(id: 'personal', name: 'Personal', colorKey: 'pink'), + ProjectProfile(id: 'work', name: 'Work', colorKey: 'work-green'), + ], + initialTasks: tasks, + ); + + final board = (await V1ApplicationManagementUseCases( + applicationStore: store, + ).getBacklogBoard( + GetBacklogBoardRequest( + context: appContext(operationId: 'bucket-board', now: now), + localDate: CivilDate(2026, 6, 25), + sortKey: BacklogSortKey.age, + ), + )) + .requireValue; + BacklogBoardColumnReadModel column(BacklogBoardBucket bucket) { + return board.columns.singleWhere((column) => column.bucket == bucket); + } + + expect(column(BacklogBoardBucket.doNext).items.single.taskId, 'quick'); + expect( + column(BacklogBoardBucket.needTimeBlock).items.single.taskId, + 'block', + ); + expect( + column(BacklogBoardBucket.breakUpFirst).items.single.taskId, + 'break', + ); + expect(column(BacklogBoardBucket.notNow).items.single.taskId, 'later'); + expect( + board.columns + .expand((column) => column.items) + .map((item) => item.taskId), + isNot(contains('planned')), + ); + expect(board.summary.totalTaskCount, 4); + expect(board.summary.totalEstimatedMinutes, 215); + expect( + board.summary.projectDistribution.entries.map((entry) => entry.label), + ['Home', 'Personal', 'Work'], + ); + expect( + board.summary.projectDistribution.entries.map((entry) => entry.count), + [1, 1, 2], + ); + }); + + test('applies backlog board search, filters, and project grouping', + () async { + final store = InMemoryApplicationUnitOfWork( + initialProjects: [ + ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'), + ProjectProfile(id: 'work', name: 'Work', colorKey: 'work-green'), + ], + initialTasks: [ + backlogTask( + id: 'home-wish', + title: 'Organize hallway closet', + projectId: 'home', + createdAt: now.subtract(const Duration(days: 1)), + backlogTags: const {BacklogTag.wishlist}, + ), + backlogTask( + id: 'work-wish', + title: 'Read optional design notes', + projectId: 'work', + createdAt: now.subtract(const Duration(days: 2)), + backlogTags: const {BacklogTag.wishlist}, + ), + backlogTask( + id: 'not-filtered', + title: 'Someday wording but normal backlog', + projectId: 'work', + createdAt: now.subtract(const Duration(days: 3)), + ), + ], + ); + + final board = (await V1ApplicationManagementUseCases( + applicationStore: store, + ).getBacklogBoard( + GetBacklogBoardRequest( + context: appContext(operationId: 'search-board', now: now), + localDate: CivilDate(2026, 6, 25), + searchText: 'someday', + filters: const [BacklogFilter.wishlist], + groupMode: BacklogBoardGroupMode.project, + ), + )) + .requireValue; + final notNow = board.columns.singleWhere( + (column) => column.bucket == BacklogBoardBucket.notNow, + ); + + expect(board.summary.totalTaskCount, 2); + expect(notNow.items.map((item) => item.taskId), [ + 'home-wish', + 'work-wish', + ]); + expect( + notNow.items.every((item) => item.tags.contains('someday')), isTrue); + expect( + board.columns + .expand((column) => column.items) + .map((item) => item.taskId), + isNot(contains('not-filtered')), + ); + }); + + test('applies backlog board filter selection and grouping', () async { + final store = InMemoryApplicationUnitOfWork( + initialProjects: [ + ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'), + ProjectProfile(id: 'work', name: 'Work', colorKey: 'work-green'), + ], + initialTasks: [ + backlogTask( + id: 'long-work', + title: 'Long work task', + projectId: 'work', + createdAt: now.subtract(const Duration(days: 3)), + priority: PriorityLevel.medium, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + durationMinutes: 75, + ), + backlogTask( + id: 'high-work', + title: 'High work task', + projectId: 'work', + createdAt: now.subtract(const Duration(days: 2)), + priority: PriorityLevel.high, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + durationMinutes: 25, + ), + backlogTask( + id: 'low-home', + title: 'Low home task', + projectId: 'home', + createdAt: now.subtract(const Duration(days: 1)), + priority: PriorityLevel.low, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + durationMinutes: 20, + ), + backlogTask( + id: 'missing-work', + title: 'Missing duration task', + projectId: 'work', + createdAt: now, + priority: PriorityLevel.medium, + reward: RewardLevel.notSet, + difficulty: DifficultyLevel.notSet, + durationMinutes: null, + ), + ], + ); + final useCases = V1ApplicationManagementUseCases( + applicationStore: store, + ); + + final filtered = (await useCases.getBacklogBoard( + GetBacklogBoardRequest( + context: appContext(operationId: 'board-filters', now: now), + localDate: CivilDate(2026, 6, 25), + boardFilters: BacklogBoardFilterSelection( + priorityGroups: const [BacklogBoardPriorityFilter.high], + projectIds: const ['work'], + durationBuckets: const [ + BacklogBoardDurationFilter.zeroToThirty, + ], + buckets: const [BacklogBoardBucket.needTimeBlock], + ), + ), + )) + .requireValue; + final filteredIds = filtered.columns + .expand((column) => column.items) + .map((item) => item.taskId); + + expect(filtered.summary.totalTaskCount, 1); + expect(filteredIds, ['high-work']); + + final missing = (await useCases.getBacklogBoard( + GetBacklogBoardRequest( + context: appContext(operationId: 'board-missing', now: now), + localDate: CivilDate(2026, 6, 25), + filters: const [BacklogFilter.noRewardSet], + boardFilters: BacklogBoardFilterSelection( + missingDurationOnly: true, + ), + ), + )) + .requireValue; + + expect(missing.summary.totalTaskCount, 1); + expect( + missing.columns.expand((column) => column.items).single.taskId, + 'missing-work', + ); + + final grouped = (await useCases.getBacklogBoard( + GetBacklogBoardRequest( + context: appContext(operationId: 'board-duration-group', now: now), + localDate: CivilDate(2026, 6, 25), + groupMode: BacklogBoardGroupMode.duration, + ), + )) + .requireValue; + final needTimeBlock = grouped.columns.singleWhere( + (column) => column.bucket == BacklogBoardBucket.needTimeBlock, + ); + + expect(needTimeBlock.items.map((item) => item.taskId), [ + 'high-work', + 'long-work', + ]); + }); + + test('loads backlog task detail with child previews', () async { + final parent = backlogTask( + id: 'parent', + title: 'Plan next week', + createdAt: now.subtract(const Duration(days: 5)), + durationMinutes: 30, + ); + final childOne = backlogTask( + id: 'child-1', + title: 'Define weekly goals', + createdAt: now.subtract(const Duration(days: 4)), + durationMinutes: 15, + parentTaskId: parent.id, + ); + final childTwo = backlogTask( + id: 'child-2', + title: 'Time block schedule', + createdAt: now.subtract(const Duration(days: 3)), + durationMinutes: 20, + parentTaskId: parent.id, + status: TaskStatus.completed, + ); + final store = InMemoryApplicationUnitOfWork( + initialTasks: [parent, childTwo, childOne], + ); + + final detail = (await V1ApplicationManagementUseCases( + applicationStore: store, + ).getBacklogTaskDetail( + GetBacklogTaskDetailRequest( + context: appContext(operationId: 'detail-children', now: now), + localDate: CivilDate(2026, 6, 25), + taskId: parent.id, + ), + )) + .requireValue; + + expect(detail.item.bucket, BacklogBoardBucket.breakUpFirst); + expect(detail.item.hasChildren, isTrue); + expect(detail.item.childPreview.map((child) => child.title), [ + 'Define weekly goals', + 'Time block schedule', + ]); + expect(detail.item.childPreview.map((child) => child.statusLabel), [ + 'Backlog', + 'Completed', + ]); + expect(detail.notes, isNull); + }); + + test('returns not found for missing or non-backlog task details', () async { + final store = InMemoryApplicationUnitOfWork( + initialTasks: [ + backlogTask( + id: 'planned', + createdAt: now, + status: TaskStatus.planned, + scheduledStart: DateTime.utc(2026, 6, 25, 14), + scheduledEnd: DateTime.utc(2026, 6, 25, 14, 30), + ), + ], + ); + final useCases = V1ApplicationManagementUseCases( + applicationStore: store, + ); + + final missing = await useCases.getBacklogTaskDetail( + GetBacklogTaskDetailRequest( + context: appContext(operationId: 'detail-missing', now: now), + localDate: CivilDate(2026, 6, 25), + taskId: 'missing', + ), + ); + final nonBacklog = await useCases.getBacklogTaskDetail( + GetBacklogTaskDetailRequest( + context: appContext(operationId: 'detail-planned', now: now), + localDate: CivilDate(2026, 6, 25), + taskId: 'planned', + ), + ); + + expect(missing.failure!.code, ApplicationFailureCode.notFound); + expect(nonBacklog.failure!.code, ApplicationFailureCode.notFound); + }); + + test('previews detail suggested slots without mutating state', () async { + final day = CivilDate(2026, 6, 25); + final candidate = backlogTask( + id: 'candidate', + title: 'Update project status', + createdAt: now, + durationMinutes: 30, + ); + final existingBlock = backlogTask( + id: 'existing-block', + title: 'Existing meeting', + createdAt: now, + type: TaskType.inflexible, + status: TaskStatus.planned, + scheduledStart: DateTime.utc(2026, 6, 25, 9), + scheduledEnd: DateTime.utc(2026, 6, 25, 10), + ); + final store = InMemoryApplicationUnitOfWork( + initialOwnerSettings: [ + OwnerSettings( + ownerId: 'owner-1', + timeZoneId: 'UTC', + dayStart: WallTime(hour: 9, minute: 0), + dayEnd: WallTime(hour: 12, minute: 0), + ), + ], + initialTasks: [candidate, existingBlock], + ); + final beforeTasks = store.currentTasks; + + final detail = (await V1ApplicationManagementUseCases( + applicationStore: store, + timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + ).getBacklogTaskDetail( + GetBacklogTaskDetailRequest( + context: appContext( + operationId: 'detail-suggestions', + now: DateTime.utc(2026, 6, 25, 8), + ), + localDate: day, + taskId: candidate.id, + ), + )) + .requireValue; + + expect(detail.suggestedSlots.first.startText, '10:00 AM'); + expect(detail.suggestedSlots.first.endText, '10:30 AM'); + expect( + detail.suggestedSlots.first.startUtc, + DateTime.utc(2026, 6, 25, 10), + ); + expect( + detail.suggestedSlots.first.endUtc, + DateTime.utc(2026, 6, 25, 10, 30), + ); + expect(detail.suggestedSlots.first.fitLabel, 'Great Fit'); + expect(detail.suggestedSlotUnavailableReason, isNull); + expect(store.currentTasks, beforeTasks); + expect(store.currentSchedulingSnapshots, isEmpty); + expect(store.currentOperations, isEmpty); + }); + + test('returns missing-duration suggestion reason without crashing', + () async { + final task = backlogTask( + id: 'missing-duration', + title: 'Clarify estimate', + createdAt: now, + durationMinutes: null, + ); + final store = InMemoryApplicationUnitOfWork(initialTasks: [task]); + + final detail = (await V1ApplicationManagementUseCases( + applicationStore: store, + ).getBacklogTaskDetail( + GetBacklogTaskDetailRequest( + context: appContext(operationId: 'detail-no-duration', now: now), + localDate: CivilDate(2026, 6, 25), + taskId: task.id, + ), + )) + .requireValue; + + expect(detail.suggestedSlots, isEmpty); + expect( + detail.suggestedSlotUnavailableReason, + 'Add an estimate to see suggested slots.', + ); + }); + test('keeps configured project defaults separate from learned suggestions', () async { final project = ProjectProfile( @@ -340,17 +970,34 @@ ApplicationOperationContext appContext({ Task backlogTask({ required String id, required DateTime createdAt, + String? title, String projectId = 'inbox', + TaskType type = TaskType.flexible, + TaskStatus status = TaskStatus.backlog, + PriorityLevel? priority = PriorityLevel.medium, + RewardLevel reward = RewardLevel.notSet, + DifficultyLevel difficulty = DifficultyLevel.notSet, + int? durationMinutes = 30, + DateTime? scheduledStart, + DateTime? scheduledEnd, + String? parentTaskId, + Set backlogTags = const {}, bool pushed = false, }) { return Task( id: id, - title: 'Task $id', + title: title ?? 'Task $id', projectId: projectId, - type: TaskType.flexible, - status: TaskStatus.backlog, - priority: PriorityLevel.medium, - durationMinutes: 30, + type: type, + status: status, + priority: priority, + reward: reward, + difficulty: difficulty, + durationMinutes: durationMinutes, + scheduledStart: scheduledStart, + scheduledEnd: scheduledEnd, + parentTaskId: parentTaskId, + backlogTags: backlogTags, createdAt: createdAt, updatedAt: createdAt, stats: pushed diff --git a/packages/scheduler_core/test/scheduling_engine_test.dart b/packages/scheduler_core/test/scheduling_engine_test.dart index 6d9e5d0..99e040f 100644 --- a/packages/scheduler_core/test/scheduling_engine_test.dart +++ b/packages/scheduler_core/test/scheduling_engine_test.dart @@ -188,7 +188,7 @@ void main() { reward: RewardLevel.low, difficulty: DifficultyLevel.hard, projectId: 'zeta', - ); + ).copyWith(durationMinutes: 90); final highPriority = Task.quickCapture( id: 'high', title: 'High', @@ -198,6 +198,7 @@ void main() { difficulty: DifficultyLevel.easy, projectId: 'alpha', ).copyWith( + durationMinutes: 15, stats: const TaskStatistics(autoPushedCount: 2), ); final mediumPriority = Task.quickCapture( @@ -209,6 +210,7 @@ void main() { difficulty: DifficultyLevel.medium, projectId: 'beta', ).copyWith( + durationMinutes: 45, stats: const TaskStatistics(manuallyPushedCount: 1), ); final view = BacklogView( @@ -216,6 +218,11 @@ void main() { now: now, ); + expect(view.sorted(BacklogSortKey.custom).map((task) => task.id), [ + 'low', + 'medium', + 'high', + ]); expect(view.sorted(BacklogSortKey.priority).map((task) => task.id), [ 'high', 'medium', @@ -235,6 +242,11 @@ void main() { 'medium', 'low', ]); + expect(view.sorted(BacklogSortKey.duration).map((task) => task.id), [ + 'high', + 'medium', + 'low', + ]); }); test('backlog staleness markers cover default thresholds', () { @@ -316,6 +328,100 @@ void main() { expect(view.stalenessMarkerFor(task), BacklogStalenessMarker.purple); }); + test('backlog board bucket policy covers all default columns', () { + const policy = BacklogBoardBucketPolicy(); + final doNext = Task.quickCapture( + id: 'do-next', + title: 'Reply to emails', + createdAt: now, + priority: PriorityLevel.medium, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + ).copyWith(durationMinutes: 20); + final needTimeBlock = Task.quickCapture( + id: 'need-time-block', + title: 'Review budget', + createdAt: now, + priority: PriorityLevel.high, + ).copyWith(durationMinutes: 30); + final breakUpFirst = Task.quickCapture( + id: 'break-up-first', + title: 'Build dashboard', + createdAt: now, + ).copyWith(durationMinutes: 120); + final notNow = Task.quickCapture( + id: 'not-now', + title: 'Visit Japan', + createdAt: now, + backlogTags: const {BacklogTag.wishlist}, + ).copyWith(durationMinutes: 20); + + expect( + policy.resolve(task: doNext).bucket, + BacklogBoardBucket.doNext, + ); + expect( + policy.resolve(task: needTimeBlock).bucket, + BacklogBoardBucket.needTimeBlock, + ); + expect( + policy.resolve(task: breakUpFirst).bucket, + BacklogBoardBucket.breakUpFirst, + ); + expect( + policy.resolve(task: notNow).bucket, + BacklogBoardBucket.notNow, + ); + }); + + test('backlog board bucket policy uses deterministic precedence', () { + const policy = BacklogBoardBucketPolicy(); + final somedayLarge = Task.quickCapture( + id: 'someday-large', + title: 'Start someday company', + createdAt: now, + backlogTags: const {BacklogTag.wishlist}, + ).copyWith(durationMinutes: 120); + final hardFocused = Task.quickCapture( + id: 'hard-focused', + title: 'Write strategy', + createdAt: now, + difficulty: DifficultyLevel.hard, + ).copyWith(durationMinutes: 60); + final lowButFocused = Task.quickCapture( + id: 'low-focused', + title: 'Organize closet', + createdAt: now, + priority: PriorityLevel.low, + ).copyWith(durationMinutes: 60); + final missingEstimate = Task.quickCapture( + id: 'missing-estimate', + title: 'Clean up files', + createdAt: now, + ); + + expect( + policy.resolve(task: somedayLarge).bucket, + BacklogBoardBucket.notNow, + ); + expect( + policy.resolve(task: hardFocused).bucket, + BacklogBoardBucket.breakUpFirst, + ); + expect( + policy.resolve(task: lowButFocused).bucket, + BacklogBoardBucket.needTimeBlock, + ); + expect( + policy.resolve(task: missingEstimate).bucket, + BacklogBoardBucket.notNow, + ); + expect( + policy.resolve(task: missingEstimate, hasChildTasks: true).bucket, + BacklogBoardBucket.breakUpFirst, + ); + }); + test('unchecked quick capture goes to backlog', () { const service = QuickCaptureService(); @@ -1286,6 +1392,80 @@ void main() { ]); }); + test('insert backlog task at requested slot preserves selected interval', + () { + final backlogTask = Task.quickCapture( + id: 'backlog-1', + title: 'Start laundry', + createdAt: now, + ).copyWith(durationMinutes: 15); + final plannedFlexible = flexibleTask().copyWith( + id: 'flexible-1', + durationMinutes: 30, + scheduledStart: DateTime(2026, 6, 19, 13, 15), + scheduledEnd: DateTime(2026, 6, 19, 13, 45), + ); + final result = engine.insertBacklogTaskAtRequestedSlot( + input: SchedulingInput( + tasks: [backlogTask, plannedFlexible], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 14), + ), + ), + taskId: 'backlog-1', + requestedStart: DateTime(2026, 6, 19, 13, 15), + requestedEnd: DateTime(2026, 6, 19, 13, 30), + updatedAt: now, + ); + final inserted = taskById(result.tasks, 'backlog-1'); + final pushed = taskById(result.tasks, 'flexible-1'); + + expect(result.operationCode, + SchedulingOperationCode.insertBacklogTaskAtRequestedSlot); + expect(inserted.scheduledStart, DateTime(2026, 6, 19, 13, 15)); + expect(inserted.scheduledEnd, DateTime(2026, 6, 19, 13, 30)); + expect(pushed.scheduledStart, DateTime(2026, 6, 19, 13, 30)); + expect(pushed.scheduledEnd, DateTime(2026, 6, 19, 14)); + expect(result.changes.map((change) => change.taskId), [ + 'backlog-1', + 'flexible-1', + ]); + }); + + test('insert backlog task at requested slot rejects blocked time', () { + final backlogTask = Task.quickCapture( + id: 'backlog-1', + title: 'Start laundry', + createdAt: now, + ).copyWith(durationMinutes: 15); + final inflexibleTask = flexibleTask().copyWith( + id: 'inflexible-1', + type: TaskType.inflexible, + scheduledStart: DateTime(2026, 6, 19, 13, 15), + scheduledEnd: DateTime(2026, 6, 19, 13, 30), + ); + final result = engine.insertBacklogTaskAtRequestedSlot( + input: SchedulingInput( + tasks: [backlogTask, inflexibleTask], + window: SchedulingWindow( + start: DateTime(2026, 6, 19, 13), + end: DateTime(2026, 6, 19, 14), + ), + ), + taskId: 'backlog-1', + requestedStart: DateTime(2026, 6, 19, 13, 15), + requestedEnd: DateTime(2026, 6, 19, 13, 30), + updatedAt: now, + ); + + expect(result.outcomeCode, SchedulingOutcomeCode.noSlot); + expect(result.changes, isEmpty); + expect( + result.notices.single.issueCode, SchedulingIssueCode.noAvailableSlot); + expect(result.tasks, [backlogTask, inflexibleTask]); + }); + test('insert backlog task skips locked time', () { final backlogTask = Task.quickCapture( id: 'backlog-1', diff --git a/packages/scheduler_persistence_sqlite/test/scheduler_db_test.dart b/packages/scheduler_persistence_sqlite/test/scheduler_db_test.dart index cdb00aa..098a5e8 100644 --- a/packages/scheduler_persistence_sqlite/test/scheduler_db_test.dart +++ b/packages/scheduler_persistence_sqlite/test/scheduler_db_test.dart @@ -46,6 +46,159 @@ void main() { ); }); + test('opens schema version 2 with expected columns', () async { + final db = SchedulerDb(NativeDatabase.memory()); + addTearDown(db.close); + + Future> columnsFor(String tableName) async { + final rows = + await db.customSelect('PRAGMA table_info("$tableName")').get(); + return rows.map((row) => row.data['name']! as String).toList(); + } + + expect(await columnsFor('tasks'), [ + 'id', + 'owner_id', + 'title', + 'project_id', + 'parent_id', + 'type', + 'status', + 'priority', + 'reward', + 'difficulty', + 'duration_minutes', + 'scheduled_start_utc', + 'scheduled_end_utc', + 'actual_start_utc', + 'actual_end_utc', + 'completed_at_utc', + 'backlog_tags_json', + 'reminder_override', + 'stats_json', + 'backlog_entered_at_utc', + 'backlog_entered_provenance', + 'revision', + 'created_at_utc', + 'updated_at_utc', + ]); + expect(await columnsFor('task_activities'), [ + 'id', + 'owner_id', + 'task_id', + 'project_id', + 'operation_id', + 'code', + 'occurred_at_utc', + 'metadata_json', + ]); + expect(await columnsFor('projects'), [ + 'id', + 'owner_id', + 'name', + 'color_key', + 'default_priority', + 'default_reward', + 'default_difficulty', + 'default_reminder_profile', + 'default_duration_minutes', + 'archived_at_utc', + 'revision', + 'created_at_utc', + 'updated_at_utc', + ]); + expect(await columnsFor('project_statistics'), [ + 'project_id', + 'owner_id', + 'completed_task_count', + 'duration_minute_counts_json', + 'completion_time_bucket_counts_json', + 'total_pushes_before_completion', + 'completed_after_push_count', + 'reward_counts_json', + 'difficulty_counts_json', + 'reminder_profile_counts_json', + 'revision', + 'created_at_utc', + 'updated_at_utc', + ]); + expect(await columnsFor('locked_blocks'), [ + 'id', + 'owner_id', + 'name', + 'date', + 'start_time', + 'end_time', + 'recurrence_json', + 'hidden_by_default', + 'project_id', + 'archived_at_utc', + 'revision', + 'created_at_utc', + 'updated_at_utc', + ]); + expect(await columnsFor('locked_overrides'), [ + 'id', + 'owner_id', + 'locked_block_id', + 'date', + 'type', + 'name', + 'start_time', + 'end_time', + 'hidden_by_default', + 'project_id', + 'revision', + 'created_at_utc', + 'updated_at_utc', + ]); + expect(await columnsFor('settings'), [ + 'owner_id', + 'timezone_id', + 'day_start_minutes', + 'day_end_minutes', + 'compact_mode', + 'backlog_staleness_json', + 'revision', + 'created_at_utc', + 'updated_at_utc', + ]); + expect(await columnsFor('snapshots'), [ + 'id', + 'owner_id', + 'captured_at_utc', + 'operation_name', + 'source_date', + 'target_date', + 'window_json', + 'tasks_json', + 'locked_intervals_json', + 'required_visible_intervals_json', + 'notices_json', + 'changes_json', + 'overlaps_json', + 'retention_expires_utc', + 'truncated', + 'revision', + 'created_at_utc', + 'updated_at_utc', + ]); + expect(await columnsFor('application_operations'), [ + 'operation_id', + 'owner_id', + 'operation_name', + 'committed_at_utc', + ]); + expect(await columnsFor('notice_acknowledgements'), [ + 'owner_id', + 'notice_id', + 'acknowledged_at_utc', + 'revision', + 'created_at_utc', + 'updated_at_utc', + ]); + }); + test('migrates schema version 1 file and preserves existing rows', () async { final directory = await Directory.systemTemp.createTemp( 'focus-flow-sqlite-migration-', diff --git a/tool/check_coverage.dart b/tool/check_coverage.dart index aa28aa7..d21f169 100644 --- a/tool/check_coverage.dart +++ b/tool/check_coverage.dart @@ -88,5 +88,18 @@ bool _includeSourceFile(String path) { final normalized = path.replaceAll('\\', '/'); return normalized.contains('/packages/') && normalized.contains('/lib/') && - !normalized.endsWith('.g.dart'); + !normalized.endsWith('.g.dart') && + !_isBuilderInputSource(normalized); +} + +/// Reports whether [path] is source input for code generation, not runtime code. +/// +/// Drift table declarations are intentionally consumed by the builder and then +/// executed through generated database classes. Runtime schema behavior is +/// covered by SQLite open and migration tests, while the generated files remain +/// excluded by the existing `.g.dart` rule above. +bool _isBuilderInputSource(String path) { + return path.contains( + '/packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/', + ); }