Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20

Open
pyr0ball wants to merge 26 commits from Circuit-Forge/focus-flow:feat/9-12-13-wire-backlog-nav-and-modal-actions into main
100 changed files with 14772 additions and 250 deletions
Showing only changes of commit 383130ebdb - Show all commits

11
.gitleaks.toml Normal file
View file

@ -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''',
]

View file

@ -1,7 +1,7 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# AGENTS.md — Codex Project Rules (SQLiteFirst, July 2026)
# AGENTS.md — Codex Project Rules (SQLiteFirst, 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. Quickstart for Codex
* Blocks live in `Codex Documentation/Current Software Plan/`.
* Work strictly in numerical order (Block19  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 | Domainonly 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 | AES256GCM encrypted SQLite file (`Backup library`, Block24) |
| Migrations | Drift migrations with tests (Block20, Block26) |
| 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 multiuser.
4. Adapters implement compareandset; 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` (Block21).
* Desktop implementation (Block22), 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` (Block23).
* 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 (Block27)
## 6. Dev Scripts (Block 27)
| Script | Description |
|--------|-------------|
| `bootstrap_dev.sh` | install deps, create dev DB |
| `dev.sh` | hotreload 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: ubuntulatest, windowslatest, macoslatest.
* 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_

View file

@ -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

View file

@ -0,0 +1,120 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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.

View file

@ -0,0 +1,328 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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<String> 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<ApplicationResult<BacklogBoardQueryResult>> 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<ApplicationResult<BacklogTaskDetailReadModel>> 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 Ashleys 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
```

View file

@ -0,0 +1,162 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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 3236 px,
- dark navy/black background,
- controls height around 4044 px,
- right summary panel around 285300 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
```

View file

@ -0,0 +1,231 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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 270300 px,
- column gap around 812 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 15 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
```

View file

@ -0,0 +1,189 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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
```

View file

@ -0,0 +1,176 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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:
- `030 min`
- `3060 min`
- `60120 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 24 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
```

View file

@ -0,0 +1,272 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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: 460470 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
```

View file

@ -0,0 +1,267 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# UI Plan 2 Block 08 — Commands, New Task, Create/Edit Metadata, and Drawer Actions
**Goal:** Wire the Backlog tabs 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
```

View file

@ -0,0 +1,263 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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.

View file

@ -0,0 +1,48 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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`

View file

@ -0,0 +1,244 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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.

View file

@ -0,0 +1,345 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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 repos 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 460470 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<BacklogBoardColumnReadModel> columns;
final List<ProjectOptionReadModel> projectOptions;
}
class BacklogBoardColumnReadModel {
final BacklogBoardBucket bucket;
final String title;
final String subtitle;
final String accentToken;
final int count;
final List<BacklogBoardItemReadModel> 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<BacklogChildPreviewReadModel> childPreview;
final List<String> tags;
}
class BacklogTaskDetailReadModel {
final BacklogBoardItemReadModel item;
final String? notes;
final String addedLabel;
final List<String> tags;
final List<ProjectOptionReadModel> projectOptions;
final List<SuggestedSlotReadModel> suggestedSlots;
}
```
Summary should include:
- total tasks,
- total estimated minutes,
- priority distribution,
- project distribution,
- duration distribution buckets: `030`, `3060`, `60120`, `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.

View file

@ -0,0 +1,16 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View file

@ -1,35 +1,59 @@
# V1 ADR 002: SQLite Document Schema V1
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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 **SQLitefirst** 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 / oneoff 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` | Datescoped 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` | Appendonly 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.
* 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.

View file

@ -1,9 +1,33 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# V1 ADR 005: Export & Backup Boundary
Status: **Accepted**
Status: **Accepted; updated to match current app state**
Date: 2026-06-26
* **Backup** passphraseencrypted SQLite copy (`.sqlite.aes` via AES256GCM, PBKDF2 200k 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.

View file

@ -1,8 +1,32 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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.

View file

@ -1,8 +1,43 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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<NotificationFeedback> 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.

View file

@ -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.

View file

@ -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/`.

View file

@ -0,0 +1,64 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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<String> 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.

View file

@ -11,9 +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. The sidebar switches between the Today and Backlog screens, and the
Backlog screen supports quick capture and scheduling backlog items.
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
@ -101,9 +111,11 @@ boundary.
## Intentionally No-op
- 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 Schedule and Protect time icons.
- Backlog drawer project selector, add-tag control, and View day shortcut.
## Persistence Proof
@ -168,6 +180,6 @@ Run these from `apps/focus_flow_flutter/`.
- Give the Compact/Normal toggle real backing state (the live Today pipeline
has no compact-mode concept yet; see Forgejo issue #11).
- Add a real Settings screen bound to existing `OwnerSettings`/
`BacklogStalenessSettings` persistence.
- Add migration-backed notes, project edits, and freeform tags for Backlog
metadata.
- Add Shield/Recovery only in a later scope.

View file

@ -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<ProjectProfile> _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<Task> _todaySeedTasks(CivilDate date, DateTime createdAt) {
@ -123,6 +147,284 @@ List<Task> _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<Task> _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<BacklogTag> backlogTags = const <BacklogTag>{},
}) {
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,
);
}

View file

@ -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 = <Task>[
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<TodayState> createTodayController() {
return ApplicationReadController<TodayState>(
@ -176,22 +199,6 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
);
}
/// Creates a generic read controller for backlog panes.
@override
UiReadController<BacklogQueryResult> createBacklogController() {
return ApplicationReadController<BacklogQueryResult>(
read: () {
return managementUseCases.getBacklog(
GetBacklogRequest(
context: context('ui-read-backlog'),
sortKey: BacklogSortKey.age,
),
);
},
isEmpty: (state) => state.items.isEmpty,
);
}
/// Creates a generic read controller for the settings screen.
@override
UiReadController<OwnerSettings> createOwnerSettingsController() {

View file

@ -9,15 +9,17 @@ 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_pane.dart';
import '../widgets/backlog/backlog_board_screen.dart';
import '../widgets/dialogs/settings_dialog.dart';
import '../widgets/required_banner.dart';
import '../widgets/sidebar.dart';

View file

@ -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<FocusFlowHome> {
/// 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,16 +22,13 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
/// 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 `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 UiReadController<BacklogQueryResult> backlogController;
/// 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<OwnerSettings> ownerSettingsController;
/// Screen currently shown in the main content area.
var _activeScreen = SidebarScreen.today;
/// 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.
@ -36,10 +37,12 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
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,
);
backlogController = widget.composition.createBacklogController();
ownerSettingsController = widget.composition
.createOwnerSettingsController();
commandController = widget.composition.createCommandController(
@ -47,9 +50,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
selectedDate: () => selectedDateController.date,
);
logger.finer(() => 'FocusFlow home triggering initial Today load.');
controller.load();
logger.finer(() => 'FocusFlow home triggering initial Backlog load.');
unawaited(backlogController.load());
todayController.load();
logger.finer(() => 'FocusFlow home triggering initial settings load.');
unawaited(ownerSettingsController.load());
}
@ -60,41 +61,14 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
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();
}
/// Reloads every read surface a command may have affected, regardless of
/// which screen is currently visible, so switching screens never shows
/// stale data.
Future<void> _refreshReads() async {
await controller.load();
await backlogController.load();
await ownerSettingsController.load();
}
/// Switches the main content area to the Today screen.
void _showToday() {
if (_activeScreen == SidebarScreen.today) {
return;
}
logger.finer(() => 'Sidebar navigation selected Today.');
setState(() => _activeScreen = SidebarScreen.today);
}
/// Switches the main content area to the Backlog screen.
void _showBacklog() {
if (_activeScreen == SidebarScreen.backlog) {
return;
}
logger.finer(() => 'Sidebar navigation selected Backlog.');
setState(() => _activeScreen = SidebarScreen.backlog);
}
/// Runs the `_toggleCardCompletion` 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<void> _toggleCardCompletion(TimelineCardModel card) async {
@ -161,7 +135,34 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
taskId: card.id,
expectedUpdatedAt: card.expectedUpdatedAt,
);
controller.clearSelection();
todayController.clearSelection();
}
/// Refreshes every read surface affected by scheduler commands.
Future<void> _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.
@ -248,46 +249,47 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
backgroundColor: FocusFlowTokens.appBackground,
body: AppShell(
sidebar: Sidebar(
activeScreen: _activeScreen,
onSelectToday: _showToday,
onSelectBacklog: _showBacklog,
onOpenSettings: () {
unawaited(_openSettings());
},
activeSection: _activeSection,
onSectionSelected: _selectSection,
),
child: switch (_activeScreen) {
SidebarScreen.today => _buildTodayContent(context),
SidebarScreen.backlog => BacklogPane(
child: switch (_activeSection) {
FocusFlowSection.today => _buildTodayScreen(),
FocusFlowSection.backlog => BacklogBoardScreen(
controller: backlogController,
commandController: commandController,
),
FocusFlowSection.projects ||
FocusFlowSection.reports ||
FocusFlowSection.settings => _SectionPlaceholder(
section: _activeSection,
),
},
),
);
}
/// Builds the compact Today timeline content for the main content area.
Widget _buildTodayContent(BuildContext context) {
/// Builds the current Today screen branch.
Widget _buildTodayScreen() {
return AnimatedBuilder(
animation: controller,
animation: todayController,
builder: (context, _) {
return switch (controller.state) {
return switch (todayController.state) {
TodayScreenLoading() => const Center(
child: CircularProgressIndicator(),
),
TodayScreenFailure(:final message) => _PlanIssue(message: message),
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
selectedCard: todayController.selectedCard,
onSelectCard: todayController.selectCard,
onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onBreakUpCard: _breakUpCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onBreakUpCard: _breakUpCard,
onClearSelection: todayController.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
@ -299,8 +301,8 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
),
TodayScreenReady(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
selectedCard: todayController.selectedCard,
onSelectCard: todayController.selectCard,
onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
@ -308,7 +310,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
onRemoveCard: _removeCard,
onBreakUpCard: _breakUpCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onClearSelection: todayController.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
@ -352,3 +354,48 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
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',
};
}
}

View file

@ -12,6 +12,7 @@ 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';
@ -205,20 +206,39 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
);
}
/// Creates a read controller for the backlog screen.
/// Creates the controller used by the Backlog board screen.
@override
UiReadController<BacklogQueryResult> createBacklogController() {
scheduler_core.logger.finer(() => 'Creating backlog read controller.');
return ApplicationReadController<BacklogQueryResult>(
read: () {
return managementUseCases.getBacklog(
GetBacklogRequest(
context: context('ui-read-backlog'),
sortKey: BacklogSortKey.age,
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,
),
);
},
isEmpty: (state) => state.items.isEmpty,
);
}

View file

@ -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';
@ -33,8 +34,10 @@ abstract interface class SchedulerAppComposition {
required SelectedDateController selectedDates,
});
/// Creates a read controller for the backlog screen.
UiReadController<BacklogQueryResult> createBacklogController();
/// Creates the controller used by the Backlog board screen.
BacklogBoardController createBacklogBoardController({
required SelectedDateController selectedDates,
});
/// Creates a read controller for the settings screen.
UiReadController<OwnerSettings> createOwnerSettingsController();

View file

@ -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<ApplicationResult<BacklogBoardQueryResult>> Function(
BacklogBoardReadRequest request,
);
/// Reads one Backlog task detail model from the scheduler application layer.
typedef BacklogTaskDetailReader =
Future<ApplicationResult<BacklogTaskDetailReadModel>> 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<BacklogFilter> filters = const <BacklogFilter>[],
this.boardFilters = const BacklogBoardFilterSelection.empty(),
this.sortKey = BacklogSortKey.custom,
this.groupMode = BacklogBoardGroupMode.none,
}) : filters = List<BacklogFilter>.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<BacklogFilter> 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<BacklogFilter> _filters = const <BacklogFilter>[];
/// 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<BacklogFilter> 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<void> 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<void> 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<void> 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<void> 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<void> updateFilters(List<BacklogFilter> value) async {
_filters = List<BacklogFilter>.unmodifiable(value);
await load();
}
/// Updates legacy and board-specific filters together and reloads once.
Future<void> updateFilterState({
required List<BacklogFilter> filters,
required BacklogBoardFilterSelection boardFilters,
}) async {
_filters = List<BacklogFilter>.unmodifiable(filters);
_boardFilters = boardFilters;
await load();
}
/// Updates board-specific filters and reloads the board.
Future<void> updateBoardFilters(BacklogBoardFilterSelection value) async {
_boardFilters = value;
await load();
}
/// Updates sort key and reloads the board.
Future<void> updateSortKey(BacklogSortKey value) async {
if (value == _sortKey) {
return;
}
_sortKey = value;
await load();
}
/// Updates group mode and reloads the board.
Future<void> updateGroupMode(BacklogBoardGroupMode value) async {
if (value == _groupMode) {
return;
}
_groupMode = value;
await load();
}
/// Retries the latest board read.
Future<void> 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<void> _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;
}
}

View file

@ -139,6 +139,116 @@ class SchedulerCommandController extends ChangeNotifier {
);
}
/// Schedules a backlog task into a specific suggested slot.
Future<void> 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<void> breakUpBacklogTask({
required String parentTaskId,
required List<ApplicationChildTaskDraft> 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<void> 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<void> 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<void> completeFlexibleTask({
required String taskId,

View file

@ -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<BacklogBoardColumnReadModel> 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;
}

View file

@ -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,
}

View file

@ -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,
};
}
}

View file

@ -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,
),
),
],
),
),
),
);
}
}

View file

@ -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<BacklogBoardTopControls> createState() =>
_BacklogBoardTopControlsState();
}
/// State for Backlog board top controls.
class _BacklogBoardTopControlsState extends State<BacklogBoardTopControls> {
/// 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<BacklogSortKey>(
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<BacklogBoardGroupMode>(
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<T> 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<T>> options;
/// Selection callback.
final ValueChanged<T> 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<T>(
color: FocusFlowTokens.elevatedPanel,
onSelected: onSelected,
itemBuilder: (context) {
return [
for (final option in options)
PopupMenuItem<T>(
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<T> {
/// 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<BacklogFilter> legacyFilters,
required this.boardFilters,
}) : legacyFilters = List<BacklogFilter>.unmodifiable(legacyFilters);
/// Legacy backlog filters to apply.
final List<BacklogFilter> 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<BacklogFilter> initialLegacyFilters;
/// Board filters active before the dialog opened.
final BacklogBoardFilterSelection initialBoardFilters;
/// Project options available to the board.
final List<ProjectOptionReadModel> 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<BacklogBoardPriorityFilter> _priorityGroups;
/// Selected project ids.
late Set<String> _projectIds;
/// Selected duration buckets.
late Set<BacklogBoardDurationFilter> _durationBuckets;
/// Selected board buckets.
late Set<BacklogBoardBucket> _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<bool> 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<T>(Set<T> 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<Widget> 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<void> _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<ProjectOptionReadModel> _projectOptionsForState(
BacklogBoardScreenState state,
) {
return switch (state) {
BacklogBoardReady(:final data) => data.board.projectOptions,
BacklogBoardEmpty(:final data) => data.board.projectOptions,
BacklogBoardLoading() ||
BacklogBoardFailure() => const <ProjectOptionReadModel>[],
};
}
/// 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'),
];

File diff suppressed because it is too large Load diff

View file

@ -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<BacklogSummaryPanel> createState() => _BacklogSummaryPanelState();
}
/// State for the Backlog summary panel collapse control.
class _BacklogSummaryPanelState extends State<BacklogSummaryPanel> {
/// 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<Widget> 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<int>(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()}%';
}

View file

@ -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<BacklogChildPreviewReadModel> 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';
}

File diff suppressed because it is too large Load diff

View file

@ -6,43 +6,23 @@ library;
import 'package:flutter/material.dart';
import '../models/navigation/focus_flow_section.dart';
import '../theme/focus_flow_tokens.dart';
/// Primary screens the sidebar can switch the main content area to.
///
/// Only [today] and [backlog] are wired to real navigation. Projects and
/// Reports remain presentational-only nav items: they are outside the V1 MVP
/// boundary (AGENTS.md section 8 scopes V1 to Today + Backlog only).
enum SidebarScreen {
/// The compact Today timeline screen.
today,
/// The backlog screen.
backlog,
}
/// Sidebar for primary FocusFlow navigation.
class Sidebar extends StatelessWidget {
/// Creates the FocusFlow sidebar with the given [activeScreen] highlighted.
/// Creates the FocusFlow sidebar.
const Sidebar({
required this.activeScreen,
required this.onSelectToday,
required this.onSelectBacklog,
required this.onOpenSettings,
required this.activeSection,
required this.onSectionSelected,
super.key,
});
/// Screen currently shown in the main content area.
final SidebarScreen activeScreen;
/// Currently active app section.
final FocusFlowSection activeSection;
/// Invoked when the Today nav item is activated.
final VoidCallback onSelectToday;
/// Invoked when the Backlog nav item is activated.
final VoidCallback onSelectBacklog;
/// Invoked when the Settings nav item is activated.
final VoidCallback onOpenSettings;
/// Callback invoked when a section is selected.
final ValueChanged<FocusFlowSection> 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.
@ -60,27 +40,43 @@ class Sidebar extends StatelessWidget {
_NavItem(
label: 'Today',
icon: Icons.calendar_today,
active: activeScreen == SidebarScreen.today,
onTap: onSelectToday,
section: FocusFlowSection.today,
active: activeSection == FocusFlowSection.today,
onTap: onSectionSelected,
),
const SizedBox(height: 16),
_NavItem(
label: 'Backlog',
icon: Icons.layers,
active: activeScreen == SidebarScreen.backlog,
onTap: onSelectBacklog,
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: onOpenSettings,
section: FocusFlowSection.settings,
active: activeSection == FocusFlowSection.settings,
onTap: onSectionSelected,
),
],
),
@ -137,6 +133,7 @@ class _NavItem extends StatelessWidget {
const _NavItem({
required this.label,
required this.icon,
required this.section,
required this.onTap,
this.active = false,
});
@ -149,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<FocusFlowSection> 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.
@ -167,11 +168,11 @@ class _NavItem extends StatelessWidget {
return Material(
color: Colors.transparent,
child: InkWell(
key: active
? ValueKey('nav-${label.toLowerCase()}-active')
: ValueKey('nav-${label.toLowerCase()}'),
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),

View file

@ -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

View file

@ -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:

View file

@ -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 = <String>[
'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<String, int> _distributionCounts(
BacklogDistributionReadModel distribution,
) {
return {for (final entry in distribution.entries) entry.label: entry.count};
}

View file

@ -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<BacklogBoardReady>());
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<BacklogBoardEmpty>());
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<BacklogBoardFailure>());
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<BacklogBoardReady>());
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<ApplicationResult<BacklogTaskDetailReadModel>> _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<int>(
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 [],
);
}

View file

@ -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 {

View file

@ -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 = <String>[];
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<AnimatedContainer>(
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<AnimatedContainer>(
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<SchedulerCommandSuccess>());
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<SchedulerCommandIdle>());
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<SchedulerCommandSuccess>());
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<SchedulerCommandSuccess>());
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<SchedulerCommandIdle>());
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<SchedulerCommandSuccess>());
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<SchedulerCommandIdle>());
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<SchedulerCommandSuccess>());
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<SchedulerCommandIdle>());
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<SchedulerCommandSuccess>());
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 = <BacklogBoardReadRequest>[];
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<void> 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<void> _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<void> _settleAsyncUi(WidgetTester tester) async {
await tester.pumpAndSettle();
await tester.runAsync(() async {
await Future<void>.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<Task> 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<Task> 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<Task> 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<ProjectOptionReadModel> 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 [],
);
}

View file

@ -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<void> _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,
);
}

View file

@ -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<void> _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,
),
],
);
}

View file

@ -0,0 +1,38 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# V1 ADR 002: SQLite Document Schema V1
Status: **Accepted**
Date: 2026-06-26
## Context
The project pivoted from MongoDB to **SQLitefirst** 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 / oneoff 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` | Datescoped 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` | Appendonly 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.

View file

@ -0,0 +1,12 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# V1 ADR 005: Export & Backup Boundary
Status: **Accepted**
Date: 2026-06-26
* **Backup** passphraseencrypted SQLite copy (`.sqlite.aes` via AES256GCM, PBKDF2 200k rounds).
* **Readable export** JSON and CSV via `ExportController`.
No open questions remain.

View file

@ -0,0 +1,11 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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.

View file

@ -0,0 +1,11 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# 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.

View file

@ -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,

View file

@ -229,6 +229,10 @@ List<String> _changedTaskIds(List<Task> beforeTasks, List<Task> 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<BacklogTag> before, Set<BacklogTag> 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) {

View file

@ -285,6 +285,88 @@ class V1ApplicationCommandUseCases {
);
}
/// Schedule an existing backlog item into a selected suggested slot.
Future<ApplicationResult<ApplicationCommandResult>>
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<ApplicationCommandResult>(
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<ApplicationResult<ApplicationCommandResult>>
pushFlexibleToNextAvailableSlot({
@ -375,6 +457,106 @@ class V1ApplicationCommandUseCases {
);
}
/// Add the Someday/Wishlist backlog tag to an active Backlog task.
Future<ApplicationResult<ApplicationCommandResult>> pushBacklogTaskToSomeday({
required ApplicationOperationContext context,
required String taskId,
DateTime? expectedUpdatedAt,
}) {
const commandCode = ApplicationCommandCode.pushBacklogTaskToSomeday;
return applicationStore.run<ApplicationCommandResult>(
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 <TaskActivity>[],
readHint: ApplicationCommandReadHint(
affectedTaskIds: [taskId],
refreshToday: false,
),
);
},
);
}
/// Hide an active Backlog task without deleting its persisted record.
Future<ApplicationResult<ApplicationCommandResult>> archiveBacklogTask({
required ApplicationOperationContext context,
required String taskId,
DateTime? expectedUpdatedAt,
}) {
const commandCode = ApplicationCommandCode.archiveBacklogTask;
return applicationStore.run<ApplicationCommandResult>(
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 <TaskActivity>[],
readHint: ApplicationCommandReadHint(
affectedTaskIds: [taskId],
refreshToday: false,
),
);
},
);
}
/// Permanently remove a task from persistence.
Future<ApplicationResult<ApplicationCommandResult>> removeTask({
required ApplicationOperationContext context,

View file

@ -18,11 +18,27 @@ 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';

View file

@ -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<BacklogBoardItemReadModel> items,
int? count,
}) : items = List<BacklogBoardItemReadModel>.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<BacklogBoardItemReadModel> items;
}

View file

@ -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<BacklogChildPreviewReadModel> childPreview,
required List<String> tags,
this.subtitle,
this.durationMinutes,
this.priority,
bool? hasChildren,
}) : childPreview =
List<BacklogChildPreviewReadModel>.unmodifiable(childPreview),
tags = List<String>.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<BacklogChildPreviewReadModel> childPreview;
/// Display-ready task-local tag labels.
final List<String> tags;
}

View file

@ -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<BacklogBoardColumnReadModel> columns,
required List<ProjectOptionReadModel> projectOptions,
}) : columns = List<BacklogBoardColumnReadModel>.unmodifiable(columns),
projectOptions = List<ProjectOptionReadModel>.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<BacklogBoardColumnReadModel> columns;
/// Project choices available to board and drawer controls.
final List<ProjectOptionReadModel> projectOptions;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<BacklogDistributionEntryReadModel> entries,
int? totalCount,
}) : entries = List<BacklogDistributionEntryReadModel>.unmodifiable(
entries,
),
totalCount = totalCount ??
entries.fold<int>(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<BacklogDistributionEntryReadModel> entries;
}

View file

@ -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<String> tags,
required List<ProjectOptionReadModel> projectOptions,
required List<SuggestedSlotReadModel> suggestedSlots,
this.notes,
this.suggestedSlotUnavailableReason,
}) : tags = List<String>.unmodifiable(tags),
projectOptions = List<ProjectOptionReadModel>.unmodifiable(
projectOptions,
),
suggestedSlots = List<SuggestedSlotReadModel>.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<String> tags;
/// Available project choices for the selected task.
final List<ProjectOptionReadModel> projectOptions;
/// Non-mutating suggested schedule slots.
final List<SuggestedSlotReadModel> suggestedSlots;
/// Display reason when slot suggestions are unavailable.
final String? suggestedSlotUnavailableReason;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<BacklogBoardPriorityFilter> priorityGroups =
const <BacklogBoardPriorityFilter>[],
Iterable<String> projectIds = const <String>[],
Iterable<BacklogBoardDurationFilter> durationBuckets =
const <BacklogBoardDurationFilter>[],
Iterable<BacklogBoardBucket> buckets = const <BacklogBoardBucket>[],
this.missingDurationOnly = false,
}) : priorityGroups = Set<BacklogBoardPriorityFilter>.unmodifiable(
priorityGroups,
),
projectIds = Set<String>.unmodifiable(projectIds),
durationBuckets = Set<BacklogBoardDurationFilter>.unmodifiable(
durationBuckets,
),
buckets = Set<BacklogBoardBucket>.unmodifiable(buckets);
/// Creates an empty board filter selection.
const BacklogBoardFilterSelection.empty()
: priorityGroups = const <BacklogBoardPriorityFilter>{},
projectIds = const <String>{},
durationBuckets = const <BacklogBoardDurationFilter>{},
buckets = const <BacklogBoardBucket>{},
missingDurationOnly = false;
/// Selected priority groups.
final Set<BacklogBoardPriorityFilter> priorityGroups;
/// Selected project ids.
final Set<String> projectIds;
/// Selected duration buckets.
final Set<BacklogBoardDurationFilter> durationBuckets;
/// Selected board buckets.
final Set<BacklogBoardBucket> 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);
}
}

View file

@ -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,
}

View file

@ -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<BacklogFilter> filters = const <BacklogFilter>[],
BacklogBoardFilterSelection? boardFilters,
this.sortKey = BacklogSortKey.custom,
this.groupMode = BacklogBoardGroupMode.none,
}) : filters = List<BacklogFilter>.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<BacklogFilter> 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;
}

View file

@ -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;
}

View file

@ -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',

View file

@ -147,6 +147,7 @@ const _schedulingIssueCodeByCode = <String, SchedulingIssueCode>{
'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,

View file

@ -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';

View file

@ -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,
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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,
}

View file

@ -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;
}

View file

@ -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,

View file

@ -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,

View file

@ -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 = <TimeInterval>[
...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 = <String, TimeInterval>{
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 = <SchedulingChange>[];
final notices = <SchedulingNotice>[];
@ -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<Task>.unmodifiable(updatedTasks),
operationCode:
SchedulingOperationCode.insertBacklogTaskIntoNextAvailableSlot,
operationCode: operationCode,
outcomeCode: SchedulingOutcomeCode.success,
notices: List<SchedulingNotice>.unmodifiable(notices),
changes: List<SchedulingChange>.unmodifiable(changes),

View file

@ -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',

View file

@ -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<BacklogTag> backlogTags = const <BacklogTag>{},
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

View file

@ -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',

View file

@ -46,6 +46,159 @@ void main() {
);
});
test('opens schema version 2 with expected columns', () async {
final db = SchedulerDb(NativeDatabase.memory());
addTearDown(db.close);
Future<List<String>> 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'), <String>[
'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'), <String>[
'id',
'owner_id',
'task_id',
'project_id',
'operation_id',
'code',
'occurred_at_utc',
'metadata_json',
]);
expect(await columnsFor('projects'), <String>[
'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'), <String>[
'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'), <String>[
'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'), <String>[
'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'), <String>[
'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'), <String>[
'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'), <String>[
'operation_id',
'owner_id',
'operation_name',
'committed_at_utc',
]);
expect(await columnsFor('notice_acknowledgements'), <String>[
'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-',

View file

@ -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/',
);
}