Compare commits

..

26 commits

Author SHA1 Message Date
b85742fcca ci: add Forgejo-native CI workflow for the Circuit-Forge fork
Some checks failed
CI / Pure-Dart workspace packages (pull_request) Has been cancelled
CI / Flutter app (analyze, test, format) (pull_request) Has been cancelled
Ashley's upstream .github/workflows/ci.yml targets GitHub-hosted
ubuntu-latest/windows-latest/macos-latest runners, which aren't
registered on git.opensourcesolarpunk.com for non-org repos - every
matrix job on eva/focus-flow's PR #18 shows "Has been cancelled"
rather than a real pass/fail. This adds a Forgejo-native workflow at
.forgejo/workflows/ci.yml using ubuntu-latest only, matching the
pattern already proven working for circuitforge-core/peregrine/kiwi
on this instance. Also runs the Flutter app's own analyze/test/format,
which the upstream workflow (root dart workspace test.dart only)
never covered.

Closes: Circuit-Forge/focus-flow#9
2026-07-10 23:15:50 -07:00
92e5358c02 chore: remove dead BacklogPane widget superseded by BacklogBoardScreen
Closes: Circuit-Forge/focus-flow#9
2026-07-10 22:48:15 -07:00
383130ebdb chore: merge upstream Backlog Board feature from Ashley's instance
Merges 20 commits from Ashley's smolblocks.com instance (main branch),
bringing in the full Backlog Board screen (BacklogBoardController, board
columns/cards, task detail drawer, summary panel, search/filter/sort/group,
Break Up and Someday actions) plus the unified FocusFlowSection navigation
model.

Reconciled with our own in-flight work:
- Replaced our placeholder BacklogPane/createBacklogController wiring with
  Ashley's real BacklogBoardScreen/BacklogBoardController (same problem,
  more complete implementation - avoids two competing Backlog UIs).
- Adopted the FocusFlowSection-based Sidebar API in place of our bespoke
  SidebarScreen enum; the Settings nav item now intercepts
  FocusFlowSection.settings in _selectSection to open our Settings dialog
  instead of falling through to a placeholder screen.
- Gave every sidebar nav item a stable key (not just the active one) so the
  Settings dialog tests can still target it reliably.
- Kept our additive owner-settings read controller, Settings dialog, and
  timeline Break-up wiring; renamed the Today controller field to
  todayController throughout to match upstream's now-multi-controller
  naming.
- Fixed a test-only regression: backlog_board_screen_test.dart's standalone
  SchedulerCommandController construction needed the new `management` param.
- Added .gitleaks.toml allowlisting the local, gitignored
  .claude/settings.local.json path (recorded bash command patterns, not
  repo secrets) that was blocking commits.

Verified via focusflow-flutter-ci:3.44.4 (Flutter 3.44.4/Dart 3.12.2):
flutter analyze clean, dart format clean, 106/106 Flutter tests passing,
348/348 scheduler_core + scheduler_persistence_sqlite tests passing.

Closes: Circuit-Forge/focus-flow#9
2026-07-10 22:46:09 -07:00
9b99c7810e feat(backlog): schedule suggested slots 2026-07-07 15:53:17 -07:00
ce997dcf2e chore(flutter): refresh dependency lock 2026-07-07 15:09:45 -07:00
bfba203d1e test(backlog): complete board validation handoff 2026-07-07 15:00:26 -07:00
3b950358c2 feat(backlog): wire drawer action commands 2026-07-07 14:44:34 -07:00
6f0da3c8aa feat(ui): wire Settings dialog to persisted OwnerSettings
Adds a getOwnerSettings read query to scheduler_core (previously only
an internal helper existed) and an updateOwnerSettings command on the
Flutter command controller, then builds a shared Settings dialog
surfacing timezone, day window, compact mode, and backlog staleness
thresholds. Both existing no-op entry points (sidebar nav item, top
bar button) now open the same dialog and persist edits through the
existing OwnerSettings/BacklogStalenessSettings backend.

Per the design spec (circuitforge-plans/focus-flow/superpowers/specs/
2026-07-07-settings-screen-design.md), the compact-mode toggle
persists but is disclosed in-UI as not yet affecting Today rendering,
since that wiring is separate scope (#11).

Closes: #16
2026-07-07 14:38:16 -07:00
1c945fad3f feat(backlog): wire create and schedule actions 2026-07-07 14:06:15 -07:00
8a1d2bfc9d feat(backlog): add task detail drawer 2026-07-07 13:52:36 -07:00
c730c33569 feat(ui): wire timeline card Break up quick action
Extracts the break-up dialog into a shared widgets/dialogs library so
the task modal and timeline card quick actions reuse the same flow
instead of duplicating it, then wires a Break up icon through
TimelineView/TaskTimelineCard to the existing breakUpTask command path.

Closes: #14
2026-07-07 13:45:04 -07:00
51ab9aa014 feat(backlog): add summary panel 2026-07-07 13:40:42 -07:00
dd25bdfaae feat(backlog): add board controls 2026-07-07 13:33:55 -07:00
2bcbba46b6 feat(backlog): render board task cards 2026-07-07 13:18:42 -07:00
73ef8c2543 feat(backlog): add board navigation shell 2026-07-07 13:04:40 -07:00
82f1fd43b7 test(backlog): seed demo board data 2026-07-07 12:53:16 -07:00
60039be127 feat(backlog): add board query use cases 2026-07-07 12:46:45 -07:00
d26645affb docs: add backlog board plan assets 2026-07-07 12:27:26 -07:00
f41a7ff034 docs(backlog): record notes metadata breakpoint 2026-07-07 12:23:34 -07:00
39326ef3dd feat(backlog): add board read models 2026-07-07 12:22:52 -07:00
b71d74290f feat(backlog): add board bucket policy 2026-07-07 12:21:11 -07:00
f8bd0e1dd6 docs(backlog): audit board read model scope 2026-07-07 12:20:05 -07:00
edfde0a2d0 docs(ui): record backlog board baseline 2026-07-07 12:15:48 -07:00
932a7409fc docs: refresh stale completed ADRs 2026-07-06 20:06:45 -07:00
88a66eff27 major: add high level api and developer guide documentation 2026-07-06 11:58:04 -07:00
c357b7d97d feat(ui): wire sidebar Backlog nav and modal Move-to-Backlog/Break-up actions
Some checks failed
CI / test (macos-latest) (push) Has been cancelled
CI / test (ubuntu-latest) (push) Has been cancelled
CI / test (windows-latest) (push) Has been cancelled
CI / package (macos-latest, macos) (push) Has been cancelled
CI / package (ubuntu-latest, linux) (push) Has been cancelled
CI / package (windows-latest, windows) (push) Has been cancelled
CI / test (macos-latest) (pull_request) Has been cancelled
CI / test (ubuntu-latest) (pull_request) Has been cancelled
CI / test (windows-latest) (pull_request) Has been cancelled
CI / package (macos-latest, macos) (pull_request) Has been cancelled
CI / package (ubuntu-latest, linux) (pull_request) Has been cancelled
CI / package (windows-latest, windows) (pull_request) Has been cancelled
Backend was already V1-complete for these paths (moveFlexibleToBacklog,
breakUpTask); this closes the UI-wiring gap identified in the V1
feature-complete audit.

- Add createBacklogController() to SchedulerAppComposition and implement it
  in PersistentSchedulerComposition (only the demo composition had one).
- Sidebar now switches the main content area between Today and Backlog via
  a real SidebarScreen enum instead of hardcoded onTap: () {} stubs.
- Task modal's "Backlog" quick-action reuses the existing onPushToBacklog
  wiring (same backend command as the Push menu's "Push to backlog").
- Add a break-up dialog (row-level title/priority/reward/duration per
  MVP-AC-11) and SchedulerCommandController.breakUpTask(), wired to the
  modal's "Break up" button.
- Fix a latent bug where the active sidebar nav item's key was hardcoded
  to nav-today-active regardless of which item was active.

Closes: Circuit-Forge/focus-flow#9
Closes: Circuit-Forge/focus-flow#12
Closes: Circuit-Forge/focus-flow#13
2026-07-06 01:46:59 -07:00
116 changed files with 16055 additions and 429 deletions

49
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,49 @@
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
# SPDX-License-Identifier: AGPL-3.0-only
# Forgejo-native CI for the Circuit-Forge focus-flow fork.
#
# Ashley's upstream .github/workflows/ci.yml targets GitHub-hosted
# ubuntu-latest/windows-latest/macos-latest runners, which don't exist on
# this self-hosted instance for non-org repos (that workflow shows every
# matrix job as "Has been cancelled" on eva/focus-flow). This workflow
# mirrors the working pattern already used by circuitforge-core, peregrine,
# and kiwi on this instance: ubuntu-latest only.
name: CI
on:
push:
branches: [main, 'feat/**', 'fix/**']
pull_request:
branches: [main]
jobs:
workspace:
name: Pure-Dart workspace packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: stable
- run: dart pub get
- run: dart analyze
- run: dart run scripts/test.dart
flutter_app:
name: Flutter app (analyze, test, format)
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/focus_flow_flutter
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.44.4
channel: stable
- run: flutter pub get
- run: flutter analyze
- run: flutter test
- run: dart format --set-exit-if-changed lib test

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-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only --> <!-- 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. 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 ## 0. Quickstart for Codex
* Blocks live in `Codex Documentation/Current Software Plan/`. * Blocks live in `Codex Documentation/Current Software Plan/`.
* Work strictly in numerical order (Block19  20 … 29). * Work strictly in numerical order unless the active plan explicitly says otherwise.
* Each block contains numbered `XHIGH` / `HIGH` chunks. * Treat each block or named plan as the primary work unit.
* Stop at every `BREAKPOINT` in a chunk; wait for confirmation before continuing. * 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. * 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 | | Element | Rule |
|---------|------| |---------|------|
| Local storage | **SQLite via Drift**, schemaVersion 1 | | Local storage | **SQLite via Drift**, schemaVersion 1 |
| Abstraction | Domainonly repository interfaces (`scheduler_persistence`) | | Abstraction | Domain-only repository interfaces (`scheduler_persistence`) |
| Swappable | New adapters must pass repository conformance tests | | Swappable | New adapters must pass repository conformance tests |
| Backup | AES256GCM encrypted SQLite file (`Backup library`, Block24) | | Backup | AES-256-GCM encrypted SQLite file (`Backup library`, Block 24) |
| Migrations | Drift migrations with tests (Block20, Block26) | | 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. 1. Interfaces expose domain objects only.
2. Optimistic `revision` on every mutable save. 2. Optimistic `revision` on every mutable save.
3. Owner scope parameter now for future multiuser. 3. Owner scope parameter now for future multi-user.
4. Adapters implement compareandset; core never overwrites stale revision. 4. Adapters implement compare-and-set; core never overwrites stale revision.
--- ---
## 3. Notification Rules ## 3. Notification Rules
* Use `NotificationAdapter` (Block21). * Use `NotificationAdapter` (Block 21).
* Desktop implementation (Block22), fake adapter for tests. * Desktop implementation (Block 22), fake adapter for tests.
* Core never imports platform APIs directly. * Core never imports platform APIs directly.
--- ---
@ -51,7 +52,7 @@ No MongoDB runtime in V1.
## 4. Backup / Export Rules ## 4. Backup / Export Rules
* Backup: encrypted SQLite (`.sqlite.aes`) via Backup library. * 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 | | Migration | `test/migration` | Drift schema upgrades |
| Integration | `test/integration` | full stack InMemory + SQLite + fake notifications | | 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 | | Script | Description |
|--------|-------------| |--------|-------------|
| `bootstrap_dev.sh` | install deps, create dev DB | | `bootstrap_dev.sh` | install deps, create dev DB |
| `dev.sh` | hotreload desktop run | | `dev.sh` | hot-reload desktop run |
| `test.sh` | all tests + coverage | | `test.sh` | all tests + coverage |
| `package_release.sh` | build OS binaries | | `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 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 format.
comments for every class, enum, enum value, constructor, method, field, and
top-level declaration. When adding code, keep large feature surfaces organized All new and modified code must be commented or documented in detail while the
under descriptive subfolders instead of expanding flat top-level `src` or app work is being done. Update existing comments and docs whenever functionality,
directories. 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 ## 7. Branch, Commit & CI
* Start each new block from `main` on a block branch named * Start each new block or plan from `main` on a dedicated branch named for the
`block-XX-simple-name` (for example, `block-20-sqlite-adapter`). work, such as `block-20-sqlite-adapter` or `plan-backlog-tab`.
* Keep all chunk work for that block on the block branch. * Keep all work for the active block or plan on its dedicated branch.
* Commit every completed chunk before moving to the next chunk or breakpoint. * 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`, * Use conventional commits (`feat`, `fix`, `docs`, `test`, `refactor`,
`chore`, `ci`). `chore`, `ci`).
* Do not leave completed chunk work only in the working tree. * When a full plan is complete and no work remains for that plan, rerun the
* When the block is complete and verified, merge the block branch back into required validation. If tests still pass, merge the plan branch back into
`main`. `main`.
* CI matrix: ubuntulatest, windowslatest, macoslatest. * CI matrix: ubuntu-latest, windows-latest, macos-latest.
* `dart analyze` and `dart test` must pass. * `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. ADRs, public API baseline, and requirements traceability matrix.
- UI Plan 1, the implemented compact Today timeline mockup plan, including its - UI Plan 1, the implemented compact Today timeline mockup plan, including its
source mockups and completed block handoff notes. 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 - Persistence Plan 1, the implemented SQLite runtime persistence slice for
normal Flutter startup and close/reopen task lifecycle proof. normal Flutter startup and close/reopen task lifecycle proof.
- Date Selection 01, the completed day navigation and date picker plan for the - 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 Date: 2026-06-26
Last reviewed: 2026-07-07
## Context ## Context
The project pivoted from MongoDB to **SQLitefirst** persistence.
We need a stable, versioned relational schema that maps cleanly to the domain The project is SQLite-first. The local app persists scheduler state in a Drift
objects while staying behind the repository abstraction. Drift will manage database and keeps storage details behind repository and application-unit-of-work
migrations. 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 ## 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** * **Tables**
| Table | Purpose | Key fields | | 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` | | `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` |
| `projects` | Project configuration | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `color_key`, config defaults …, `archived_at_utc`, `revision` | | `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` |
| `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` | | `projects` | Project configuration | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `color_key`, configured defaults, `archived_at_utc`, `revision`, `created_at_utc`, `updated_at_utc` |
| `locked_overrides` | Datescoped overrides | `id TEXT PRIMARY KEY`, `owner_id`, `locked_block_id`, `date TEXT`, `type`, JSON fields | | `project_statistics` | Project-level aggregate statistics | `project_id PRIMARY KEY`, `owner_id`, completion and suggestion-count fields, `revision`, `created_at_utc`, `updated_at_utc` |
| `settings` | One row per owner | `owner_id TEXT PRIMARY KEY`, `timezone_id`, `day_start_minutes`, `day_end_minutes`, `compact_mode INT`, `revision` | | `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 |
| `activities` | Appendonly internal events | `id TEXT PRIMARY KEY`, `owner_id`, `task_id`, `code`, `occurred_at_utc`, `metadata_json` | | `locked_overrides` | Date-scoped locked-block overrides | `id TEXT PRIMARY KEY`, `owner_id`, `locked_block_id`, `date`, override type and payload fields |
| `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` | | `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**. * UTC timestamps are stored in Drift `DateTime` columns. Civil dates and wall
* Optimistic concurrency: every mutable row carries `revision`. Updates must times remain text fields where the domain model requires calendar-local values.
include `WHERE revision = :expected` and increment on success. * Mutable domain/application records carry optimistic `revision` values. Updates
* Drift migrations are generated and tested; downgrades are not supported. 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 ## Consequences
* Domain objects remain unchanged; adapters translate.
* Backup library (Block 24) can copy and encrypt this single file. * Domain objects remain unchanged; adapters translate between domain objects and
* Repository contract tests assert schema integrity via Drift introspection. 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 # V1 ADR 005: Export & Backup Boundary
Status: **Accepted** Status: **Accepted; updated to match current app state**
Date: 2026-06-26 Date: 2026-06-26
* **Backup** passphraseencrypted SQLite copy (`.sqlite.aes` via AES256GCM, PBKDF2 200k rounds). Last reviewed: 2026-07-07
* **Readable export** JSON and CSV via `ExportController`.
## 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. 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 # V1 ADR 006: Schedule Snapshot Policy
Status: **Accepted** Status: **Accepted; updated to match current app state**
Date: 2026-06-26 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 # V1 ADR 008: Notification Abstraction
Status: **Accepted** Status: **Accepted; updated to match current app state**
Date: 2026-06-26 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,10 +11,19 @@ The app launches into the compact Today timeline. Normal startup now opens an
on-disk SQLite database and bootstraps owner settings plus the default Home on-disk SQLite database and bootstraps owner settings plus the default Home
project when they are missing. project when they are missing.
The compact Today screen can toggle completion for currently rendered task cards. The sidebar Backlog navigation opens the board surface backed by public
cards. Backlog widgets and quick-capture controls exist for tests and focused scheduler application APIs. Backlog supports the four board buckets, search,
composition paths, but the sidebar Backlog navigation remains out of scope for filters, sort/group controls, summary data, the right-anchored detail drawer,
this screen. 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 ## Runtime Data
@ -102,13 +111,11 @@ boundary.
## Intentionally No-op ## Intentionally No-op
- Sidebar navigation. - Compact/Normal toggle (Settings persists the flag, but the Today pipeline
- Date navigation and calendar buttons. does not yet read it; see Forgejo issue #11).
- Compact/Normal toggle.
- Settings button.
- Show upcoming. - Show upcoming.
- Timeline card action icons. - Timeline card Schedule and Protect time icons.
- Modal Push, Move to Backlog, and Break up buttons. - Backlog drawer project selector, add-tag control, and View day shortcut.
## Persistence Proof ## Persistence Proof
@ -171,7 +178,8 @@ Run these from `apps/focus_flow_flutter/`.
## Next Plans ## Next Plans
- Wire visible Backlog navigation and quick-capture UI into the main shell. - Give the Compact/Normal toggle real backing state (the live Today pipeline
- Wire functional Push, Move to Backlog, and Break up actions. has no compact-mode concept yet; see Forgejo issue #11).
- Add real navigation/settings. - Add migration-backed notes, project edits, and freeform tags for Backlog
metadata.
- Add Shield/Recovery only in a later scope. - Add Shield/Recovery only in a later scope.

View file

@ -3,6 +3,30 @@
part of '../demo_scheduler_composition.dart'; 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. /// 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. /// 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) { 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. /// 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. /// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
Task _seedTask({ Task _seedTask({
@ -161,3 +463,34 @@ Task _seedTask({
updatedAt: completedAt ?? createdAt, 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 'package:scheduler_core/scheduler_core.dart';
import '../controllers/backlog_board_controller.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart'; import '../controllers/scheduler_read_controller.dart';
import '../controllers/selected_date_controller.dart'; import '../controllers/selected_date_controller.dart';
@ -91,24 +92,13 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
CivilDate.fromDateTime((clock ?? const SystemClock()).now()); CivilDate.fromDateTime((clock ?? const SystemClock()).now());
final readAt = _instant(resolvedDate, 15, 55); final readAt = _instant(resolvedDate, 15, 55);
final createdAt = _instant(resolvedDate, 8); final createdAt = _instant(resolvedDate, 8);
final project = ProjectProfile( final projects = _demoProjects();
id: projectId,
name: 'Home',
colorKey: 'project-home',
);
final tasks = <Task>[ final tasks = <Task>[
if (includeTodayItems) ..._todaySeedTasks(resolvedDate, createdAt), if (includeTodayItems) ..._todaySeedTasks(resolvedDate, createdAt),
if (includeBacklogItems) if (includeBacklogItems) ..._backlogSeedTasks(resolvedDate, createdAt),
Task.quickCapture(
id: 'later-backlog-note',
title: 'Review reusable grocery list',
projectId: projectId,
createdAt: createdAt,
updatedAt: createdAt,
),
]; ];
final store = InMemoryApplicationUnitOfWork( final store = InMemoryApplicationUnitOfWork(
initialProjects: [project], initialProjects: projects,
initialOwnerSettings: [ initialOwnerSettings: [
OwnerSettings( OwnerSettings(
ownerId: ownerId, 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. /// Creates a generic read controller for Today-state panes.
UiReadController<TodayState> createTodayController() { UiReadController<TodayState> createTodayController() {
return ApplicationReadController<TodayState>( return ApplicationReadController<TodayState>(
@ -176,18 +199,16 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
); );
} }
/// Creates a generic read controller for backlog panes. /// Creates a generic read controller for the settings screen.
UiReadController<BacklogQueryResult> createBacklogController() { @override
return ApplicationReadController<BacklogQueryResult>( UiReadController<OwnerSettings> createOwnerSettingsController() {
return ApplicationReadController<OwnerSettings>(
read: () { read: () {
return managementUseCases.getBacklog( return managementUseCases.getOwnerSettings(
GetBacklogRequest( GetOwnerSettingsRequest(context: context('ui-read-owner-settings')),
context: context('ui-read-backlog'),
sortKey: BacklogSortKey.age,
),
); );
}, },
isEmpty: (state) => state.items.isEmpty, isEmpty: (_) => false,
); );
} }
@ -199,6 +220,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
}) { }) {
return SchedulerCommandController( return SchedulerCommandController(
commands: commandUseCases, commands: commandUseCases,
management: managementUseCases,
contextFor: context, contextFor: context,
selectedDate: selectedDate, selectedDate: selectedDate,
refreshReads: refreshReads, refreshReads: refreshReads,

View file

@ -9,13 +9,18 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart'; import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/backlog_board_controller.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart';
import '../controllers/selected_date_controller.dart'; import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
import '../models/navigation/focus_flow_section.dart';
import '../models/today_screen_models.dart'; import '../models/today_screen_models.dart';
import '../theme/focus_flow_theme.dart'; import '../theme/focus_flow_theme.dart';
import '../theme/focus_flow_tokens.dart'; import '../theme/focus_flow_tokens.dart';
import '../widgets/app_shell.dart'; import '../widgets/app_shell.dart';
import '../widgets/backlog/backlog_board_screen.dart';
import '../widgets/dialogs/settings_dialog.dart';
import '../widgets/required_banner.dart'; import '../widgets/required_banner.dart';
import '../widgets/sidebar.dart'; import '../widgets/sidebar.dart';
import '../widgets/task_selection_modal.dart'; import '../widgets/task_selection_modal.dart';

View file

@ -6,9 +6,13 @@ part of '../focus_flow_app.dart';
/// Private implementation type for `_FocusFlowHomeState` in this library. /// 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. /// 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> { 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. /// 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. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
@ -18,6 +22,14 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
late final SchedulerCommandController commandController; late final SchedulerCommandController commandController;
/// Stores the `ownerSettingsController` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
late final UiReadController<OwnerSettings> ownerSettingsController;
/// Private state stored as `_activeSection` for this implementation.
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
FocusFlowSection _activeSection = FocusFlowSection.today;
/// Initializes state owned by this object before the first build. /// 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. /// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
@override @override
@ -25,15 +37,22 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
super.initState(); super.initState();
logger.debug(() => 'FocusFlow home initializing controllers.'); logger.debug(() => 'FocusFlow home initializing controllers.');
selectedDateController = widget.composition.createSelectedDateController(); selectedDateController = widget.composition.createSelectedDateController();
controller = widget.composition.createTodayScreenController( todayController = widget.composition.createTodayScreenController(
selectedDates: selectedDateController, selectedDates: selectedDateController,
); );
backlogController = widget.composition.createBacklogBoardController(
selectedDates: selectedDateController,
);
ownerSettingsController = widget.composition
.createOwnerSettingsController();
commandController = widget.composition.createCommandController( commandController = widget.composition.createCommandController(
refreshReads: controller.load, refreshReads: _refreshReads,
selectedDate: () => selectedDateController.date, selectedDate: () => selectedDateController.date,
); );
logger.finer(() => 'FocusFlow home triggering initial Today load.'); logger.finer(() => 'FocusFlow home triggering initial Today load.');
controller.load(); todayController.load();
logger.finer(() => 'FocusFlow home triggering initial settings load.');
unawaited(ownerSettingsController.load());
} }
/// Releases resources owned by this object before it leaves the tree. /// Releases resources owned by this object before it leaves the tree.
@ -42,7 +61,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
void dispose() { void dispose() {
logger.debug(() => 'FocusFlow home disposing controllers.'); logger.debug(() => 'FocusFlow home disposing controllers.');
commandController.dispose(); commandController.dispose();
controller.dispose(); backlogController.dispose();
todayController.dispose();
ownerSettingsController.dispose();
selectedDateController.dispose(); selectedDateController.dispose();
unawaited(widget.composition.dispose()); unawaited(widget.composition.dispose());
super.dispose(); super.dispose();
@ -114,7 +135,78 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
taskId: card.id, taskId: card.id,
expectedUpdatedAt: card.expectedUpdatedAt, 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.
Future<void> _breakUpCard(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
) async {
logger.debug(
() =>
'Timeline break-up requested. cardId=${card.id} '
'childCount=${children.length}',
);
await commandController.breakUpTask(
parentTaskId: card.id,
children: children,
expectedUpdatedAt: card.expectedUpdatedAt,
);
}
/// Opens the settings dialog seeded with the current owner settings and
/// applies the confirmed patch, if any.
Future<void> _openSettings() async {
logger.finer(() => 'Settings action requested.');
final state = ownerSettingsController.state;
final current = switch (state) {
SchedulerReadData(:final value) => value,
SchedulerReadEmpty(:final value) => value,
_ => null,
};
if (current == null) {
logger.warn(
() => 'Settings action ignored: owner settings not yet loaded.',
);
return;
}
final update = await showDialog<OwnerSettingsUpdate>(
context: context,
builder: (_) => SettingsDialog(initial: current),
);
if (update == null) {
logger.finer(() => 'Settings dialog dismissed without changes.');
return;
}
await commandController.updateOwnerSettings(update);
} }
/// Moves the visible planning date backward by one day. /// Moves the visible planning date backward by one day.
@ -156,58 +248,83 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
return Scaffold( return Scaffold(
backgroundColor: FocusFlowTokens.appBackground, backgroundColor: FocusFlowTokens.appBackground,
body: AppShell( body: AppShell(
sidebar: const Sidebar(), sidebar: Sidebar(
child: AnimatedBuilder( activeSection: _activeSection,
animation: controller, onSectionSelected: _selectSection,
builder: (context, _) {
return switch (controller.state) {
TodayScreenLoading() => const Center(
child: CircularProgressIndicator(),
),
TodayScreenFailure(:final message) => _PlanIssue(
message: message,
),
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
unawaited(_chooseDate());
},
),
TodayScreenReady(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
unawaited(_chooseDate());
},
),
};
},
), ),
child: switch (_activeSection) {
FocusFlowSection.today => _buildTodayScreen(),
FocusFlowSection.backlog => BacklogBoardScreen(
controller: backlogController,
commandController: commandController,
),
FocusFlowSection.projects ||
FocusFlowSection.reports ||
FocusFlowSection.settings => _SectionPlaceholder(
section: _activeSection,
),
},
), ),
); );
} }
/// Builds the current Today screen branch.
Widget _buildTodayScreen() {
return AnimatedBuilder(
animation: todayController,
builder: (context, _) {
return switch (todayController.state) {
TodayScreenLoading() => const Center(
child: CircularProgressIndicator(),
),
TodayScreenFailure(:final message) => _PlanIssue(message: message),
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: todayController.selectedCard,
onSelectCard: todayController.selectCard,
onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask,
onBreakUpCard: _breakUpCard,
onClearSelection: todayController.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
unawaited(_chooseDate());
},
onOpenSettings: () {
unawaited(_openSettings());
},
),
TodayScreenReady(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: todayController.selectedCard,
onSelectCard: todayController.selectCard,
onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onBreakUpCard: _breakUpCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: todayController.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
unawaited(_chooseDate());
},
onOpenSettings: () {
unawaited(_openSettings());
},
),
};
},
);
}
/// Builds the dark theme wrapper used by the date picker dialog. /// Builds the dark theme wrapper used by the date picker dialog.
Widget _buildDatePickerTheme(BuildContext context, Widget? child) { Widget _buildDatePickerTheme(BuildContext context, Widget? child) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -237,3 +354,48 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
return CivilDate(dateTime.year, dateTime.month, dateTime.day); 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

@ -17,11 +17,13 @@ class _TodayScreenScaffold extends StatefulWidget {
required this.onPushToTomorrow, required this.onPushToTomorrow,
required this.onPushToBacklog, required this.onPushToBacklog,
required this.onRemoveCard, required this.onRemoveCard,
required this.onBreakUpCard,
required this.onAddTask, required this.onAddTask,
required this.onClearSelection, required this.onClearSelection,
required this.onPreviousDate, required this.onPreviousDate,
required this.onNextDate, required this.onNextDate,
required this.onChooseDate, required this.onChooseDate,
required this.onOpenSettings,
}); });
/// Stores the `data` value for this object. /// Stores the `data` value for this object.
@ -56,6 +58,14 @@ class _TodayScreenScaffold extends StatefulWidget {
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function(TimelineCardModel card) onRemoveCard; final Future<void> Function(TimelineCardModel card) onRemoveCard;
/// Stores the `onBreakUpCard` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
)
onBreakUpCard;
/// Stores the `onAddTask` value for this object. /// Stores the `onAddTask` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function() onAddTask; final Future<void> Function() onAddTask;
@ -76,6 +86,10 @@ class _TodayScreenScaffold extends StatefulWidget {
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback onChooseDate; final VoidCallback onChooseDate;
/// Stores the `onOpenSettings` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback onOpenSettings;
/// Creates the mutable state object used by this stateful widget. /// 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. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override @override

View file

@ -79,6 +79,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
onNextDate: widget.onNextDate, onNextDate: widget.onNextDate,
onDatePressed: widget.onChooseDate, onDatePressed: widget.onChooseDate,
onDateLabelPressed: _scrollTimelineToCurrentTime, onDateLabelPressed: _scrollTimelineToCurrentTime,
onOpenSettings: widget.onOpenSettings,
), ),
if (widget.data.requiredBanner == null) if (widget.data.requiredBanner == null)
const SizedBox(height: 16) const SizedBox(height: 16)
@ -103,6 +104,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
onPushToNext: widget.onPushToNext, onPushToNext: widget.onPushToNext,
onPushToTomorrow: widget.onPushToTomorrow, onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog, onPushToBacklog: widget.onPushToBacklog,
onBreakUpCard: widget.onBreakUpCard,
now: () => now: () =>
DateTime.now().toUtc().add(widget.data.timeZoneOffset), DateTime.now().toUtc().add(widget.data.timeZoneOffset),
), ),
@ -134,6 +136,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
onPushToTomorrow: widget.onPushToTomorrow, onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog, onPushToBacklog: widget.onPushToBacklog,
onRemove: widget.onRemoveCard, onRemove: widget.onRemoveCard,
onBreakUp: widget.onBreakUpCard,
), ),
), ),
], ],

View file

@ -12,7 +12,9 @@ import 'package:scheduler_core/scheduler_core.dart'
show logger; show logger;
import 'package:scheduler_persistence_sqlite/sqlite.dart'; import 'package:scheduler_persistence_sqlite/sqlite.dart';
import '../controllers/backlog_board_controller.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart';
import '../controllers/selected_date_controller.dart'; import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
import 'runtime/focus_flow_file_logger.dart'; import 'runtime/focus_flow_file_logger.dart';
@ -204,6 +206,58 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
); );
} }
/// Creates the controller used by the Backlog board screen.
@override
BacklogBoardController createBacklogBoardController({
required SelectedDateController selectedDates,
}) {
scheduler_core.logger.finer(
() => 'Creating backlog board controller. date=${date.toIsoString()}',
);
return BacklogBoardController(
selectedDates: selectedDates,
dateLabelFor: dateLabelFor,
readBoard: (request) {
return managementUseCases.getBacklogBoard(
GetBacklogBoardRequest(
context: context('ui-read-backlog-board'),
localDate: request.localDate,
searchText: request.searchText,
filters: request.filters,
boardFilters: request.boardFilters,
sortKey: request.sortKey,
groupMode: request.groupMode,
),
);
},
readTaskDetail: (taskId, localDate) {
return managementUseCases.getBacklogTaskDetail(
GetBacklogTaskDetailRequest(
context: context('ui-read-backlog-detail'),
localDate: localDate,
taskId: taskId,
),
);
},
);
}
/// Creates a read controller for the settings screen.
@override
UiReadController<OwnerSettings> createOwnerSettingsController() {
scheduler_core.logger.finer(
() => 'Creating owner settings read controller.',
);
return ApplicationReadController<OwnerSettings>(
read: () {
return managementUseCases.getOwnerSettings(
GetOwnerSettingsRequest(context: context('ui-read-owner-settings')),
);
},
isEmpty: (_) => false,
);
}
/// Creates the command controller used by interactive scheduler controls. /// Creates the command controller used by interactive scheduler controls.
@override @override
SchedulerCommandController createCommandController({ SchedulerCommandController createCommandController({
@ -216,6 +270,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
); );
return SchedulerCommandController( return SchedulerCommandController(
commands: commandUseCases, commands: commandUseCases,
management: managementUseCases,
contextFor: context, contextFor: context,
selectedDate: selectedDate, selectedDate: selectedDate,
refreshReads: refreshReads, refreshReads: refreshReads,

View file

@ -6,7 +6,9 @@ library;
import 'package:scheduler_core/scheduler_core.dart'; import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/backlog_board_controller.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart';
import '../controllers/selected_date_controller.dart'; import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
@ -32,6 +34,14 @@ abstract interface class SchedulerAppComposition {
required SelectedDateController selectedDates, required SelectedDateController selectedDates,
}); });
/// Creates the controller used by the Backlog board screen.
BacklogBoardController createBacklogBoardController({
required SelectedDateController selectedDates,
});
/// Creates a read controller for the settings screen.
UiReadController<OwnerSettings> createOwnerSettingsController();
/// Creates the command controller used by interactive scheduler controls. /// Creates the command controller used by interactive scheduler controls.
SchedulerCommandController createCommandController({ SchedulerCommandController createCommandController({
required ReadRefresh refreshReads, required ReadRefresh refreshReads,

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

@ -8,6 +8,7 @@ class SchedulerCommandController extends ChangeNotifier {
/// Creates a command controller from scheduler use cases and UI callbacks. /// Creates a command controller from scheduler use cases and UI callbacks.
SchedulerCommandController({ SchedulerCommandController({
required this.commands, required this.commands,
required this.management,
required this.contextFor, required this.contextFor,
required this.selectedDate, required this.selectedDate,
required this.refreshReads, required this.refreshReads,
@ -21,6 +22,10 @@ class SchedulerCommandController extends ChangeNotifier {
/// Scheduler write use cases invoked by this controller. /// Scheduler write use cases invoked by this controller.
final V1ApplicationCommandUseCases commands; final V1ApplicationCommandUseCases commands;
/// Non-scheduling management use cases (owner settings, projects, locked
/// time) invoked by this controller.
final V1ApplicationManagementUseCases management;
/// Factory for command-scoped application operation contexts. /// Factory for command-scoped application operation contexts.
final OperationContextFactory contextFor; final OperationContextFactory contextFor;
@ -134,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. /// Completes a planned flexible task from the Today view.
Future<void> completeFlexibleTask({ Future<void> completeFlexibleTask({
required String taskId, required String taskId,
@ -184,7 +299,7 @@ class SchedulerCommandController extends ChangeNotifier {
TaskType.locked || TaskType.locked ||
TaskType.surprise || TaskType.surprise ||
TaskType.freeSlot => Future.value( TaskType.freeSlot => Future.value(
ApplicationResult.failure( ApplicationResult<ApplicationCommandResult>.failure(
ApplicationFailure( ApplicationFailure(
code: ApplicationFailureCode.validation, code: ApplicationFailureCode.validation,
entityId: taskId, entityId: taskId,
@ -304,6 +419,59 @@ class SchedulerCommandController extends ChangeNotifier {
); );
} }
/// Breaks a parent task into direct child tasks.
Future<void> breakUpTask({
required String parentTaskId,
required List<ApplicationChildTaskDraft> children,
DateTime? expectedUpdatedAt,
}) async {
final commandAt = now();
logger.debug(
() =>
'UI command requested: break up task. '
'parentTaskId=$parentTaskId childCount=${children.length} '
'hasExpectedUpdatedAt=${expectedUpdatedAt != null}',
);
await _run(
label: 'Breaking up task',
successMessage: 'Task broken up',
refreshAfterSuccess: true,
action: (operationId) {
return commands.breakUpTask(
context: contextFor(operationId, now: commandAt),
parentTaskId: parentTaskId,
children: children,
expectedUpdatedAt: expectedUpdatedAt,
);
},
);
}
/// Applies a patch to the owner's persisted settings.
Future<void> updateOwnerSettings(OwnerSettingsUpdate update) async {
final commandAt = now();
logger.debug(
() =>
'UI command requested: update owner settings. '
'hasTimeZone=${update.timeZoneId != null} '
'hasDayStart=${update.dayStart != null} '
'hasDayEnd=${update.dayEnd != null} '
'hasCompactMode=${update.compactModeEnabled != null} '
'hasStaleness=${update.backlogStalenessSettings != null}',
);
await _run<OwnerSettingsUpdateResult>(
label: 'Saving settings',
successMessage: 'Settings saved',
refreshAfterSuccess: true,
action: (operationId) {
return management.updateOwnerSettings(
context: contextFor(operationId, now: commandAt),
update: update,
);
},
);
}
/// Permanently removes a task from persistence. /// Permanently removes a task from persistence.
Future<void> removeTask({ Future<void> removeTask({
required String taskId, required String taskId,
@ -332,14 +500,11 @@ class SchedulerCommandController extends ChangeNotifier {
/// Runs the `_run` operation and completes after its asynchronous work finishes. /// Runs the `_run` operation and completes after its asynchronous work finishes.
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
Future<void> _run({ Future<void> _run<R>({
required String label, required String label,
required String successMessage, required String successMessage,
required bool refreshAfterSuccess, required bool refreshAfterSuccess,
required Future<ApplicationResult<ApplicationCommandResult>> Function( required Future<ApplicationResult<R>> Function(String operationId) action,
String operationId,
)
action,
}) async { }) async {
final operationId = _nextOperationId(); final operationId = _nextOperationId();
logger.debug( logger.debug(

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

@ -1,182 +0,0 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the Backlog Pane widget for the FocusFlow Flutter UI.
library;
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart';
import 'read_state_view.dart';
import 'schedule_components.dart';
/// Read-driven pane that renders backlog content and command controls.
class BacklogPane extends StatelessWidget {
/// Creates a backlog pane from a read [controller].
const BacklogPane({
required this.controller,
this.commandController,
super.key,
});
/// Controller that loads backlog data.
final UiReadController<BacklogQueryResult> controller;
/// Optional command controller for mutating backlog items.
final SchedulerCommandController? commandController;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return ReadStateView<BacklogQueryResult>(
controller: controller,
title: 'Backlog',
emptyTitle: 'Backlog is clear',
emptyMessage: 'Captured tasks will appear here when they need planning.',
dataBuilder: (context, result) =>
BacklogContent(result: result, commandController: commandController),
);
}
}
/// Populated backlog view for a loaded backlog query result.
class BacklogContent extends StatelessWidget {
/// Creates backlog content for [result].
const BacklogContent({
required this.result,
this.commandController,
super.key,
});
/// Loaded backlog result to display.
final BacklogQueryResult result;
/// Optional command controller for capture and scheduling actions.
final SchedulerCommandController? commandController;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Backlog', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 12),
if (commandController != null) ...[
QuickCaptureForm(commandController: commandController!),
const SizedBox(height: 12),
CommandStatusBanner(controller: commandController!),
const SizedBox(height: 12),
],
for (final item in result.items)
BacklogRow(
item: item,
onSchedule: commandController == null
? null
: (durationMinutes) {
return commandController!.scheduleBacklogItem(
taskId: item.task.id,
durationMinutes: durationMinutes,
expectedUpdatedAt: item.task.updatedAt,
);
},
),
],
);
}
}
/// Small form for quickly capturing a task into the backlog.
class QuickCaptureForm extends StatefulWidget {
/// Creates a quick capture form.
const QuickCaptureForm({required this.commandController, super.key});
/// Command controller used to submit captured task titles.
final SchedulerCommandController commandController;
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
State<QuickCaptureForm> createState() => _QuickCaptureFormState();
}
/// Private implementation type for `_QuickCaptureFormState` in this library.
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
class _QuickCaptureFormState extends State<QuickCaptureForm> {
/// Stores the `titleController` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final titleController = TextEditingController();
/// Releases resources owned by this object before it leaves the tree.
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
@override
void dispose() {
titleController.dispose();
super.dispose();
}
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: TextField(
key: const ValueKey('quick-capture-title'),
controller: titleController,
decoration: const InputDecoration(labelText: 'Quick capture'),
textInputAction: TextInputAction.done,
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () async {
final title = titleController.text.trim();
if (title.isEmpty) {
return;
}
await widget.commandController.quickCaptureToBacklog(title);
titleController.clear();
},
icon: const Icon(Icons.add),
label: const Text('Capture'),
),
],
);
}
}
/// Displays the latest command state for backlog interactions.
class CommandStatusBanner extends StatelessWidget {
/// Creates a command status banner.
const CommandStatusBanner({required this.controller, super.key});
/// Command controller whose state should be rendered.
final SchedulerCommandController controller;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
return switch (controller.state) {
SchedulerCommandIdle() => const SizedBox.shrink(),
SchedulerCommandRunning(label: final label) => Text(label),
SchedulerCommandSuccess(message: final message) => Text(message),
SchedulerCommandFailure(failure: final failure, error: final error) =>
Text(
'Command issue: '
'${failure?.detailCode ?? failure?.code.name ?? error ?? 'unexpected'}',
),
};
},
);
}
}

View file

@ -0,0 +1,259 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the shared Break Up dialog for the FocusFlow Flutter UI.
library;
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../../theme/focus_flow_tokens.dart';
/// Editable child-task row state owned by [BreakUpDialog].
class _BreakUpRow {
/// Creates an empty break-up row with fresh text controllers.
_BreakUpRow()
: titleController = TextEditingController(),
durationController = TextEditingController();
/// Controls the row's child task title field.
final TextEditingController titleController;
/// Controls the row's optional duration-in-minutes field.
final TextEditingController durationController;
/// Optional priority selected for this row.
PriorityLevel? priority;
/// Reward level selected for this row.
RewardLevel reward = RewardLevel.notSet;
/// Releases the text controllers owned by this row.
void dispose() {
titleController.dispose();
durationController.dispose();
}
}
/// Dialog that collects one or more child task drafts to break up a parent
/// task, per MVP-AC-11 (row-level priority, reward, and duration).
///
/// Shared by the task selection modal and timeline card quick actions so
/// both surfaces reuse the same break-up flow instead of duplicating it.
class BreakUpDialog extends StatefulWidget {
/// Creates a break-up dialog.
const BreakUpDialog({super.key});
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
State<BreakUpDialog> createState() => _BreakUpDialogState();
}
/// Private implementation type for `_BreakUpDialogState` in this library.
/// It owns the growable list of child-task rows shown by the dialog.
class _BreakUpDialogState extends State<BreakUpDialog> {
/// Editable rows currently shown in the dialog.
final List<_BreakUpRow> _rows = [_BreakUpRow()];
/// Releases resources owned by this object before it leaves the tree.
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
@override
void dispose() {
for (final row in _rows) {
row.dispose();
}
super.dispose();
}
/// Whether at least one row has a non-empty title.
bool get _canSubmit =>
_rows.any((row) => row.titleController.text.trim().isNotEmpty);
/// Appends a new empty row.
void _addRow() {
setState(() => _rows.add(_BreakUpRow()));
}
/// Removes the row at [index], keeping at least one row.
void _removeRow(int index) {
if (_rows.length <= 1) {
return;
}
setState(() {
_rows.removeAt(index).dispose();
});
}
/// Builds child task drafts from rows with a non-empty title and closes
/// the dialog with the resulting list.
void _submit() {
final drafts = <ApplicationChildTaskDraft>[
for (final row in _rows)
if (row.titleController.text.trim().isNotEmpty)
ApplicationChildTaskDraft(
title: row.titleController.text.trim(),
priority: row.priority,
reward: row.reward,
durationMinutes: int.tryParse(row.durationController.text.trim()),
),
];
Navigator.of(context).pop(drafts);
}
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: FocusFlowTokens.elevatedPanel,
title: const Text(
'Break up task',
style: TextStyle(color: FocusFlowTokens.textPrimary),
),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < _rows.length; i++)
_BreakUpRowFields(
key: ValueKey('break-up-row-$i'),
rowIndex: i,
row: _rows[i],
onChanged: () => setState(() {}),
onRemove: _rows.length > 1 ? () => _removeRow(i) : null,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const ValueKey('break-up-add-row'),
onPressed: _addRow,
icon: const Icon(Icons.add),
label: const Text('Add another task'),
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
key: const ValueKey('break-up-confirm'),
onPressed: _canSubmit ? _submit : null,
child: const Text('Break up'),
),
],
);
}
}
/// Editable fields for a single [_BreakUpRow].
class _BreakUpRowFields extends StatelessWidget {
/// Creates fields bound to [row] at [rowIndex].
const _BreakUpRowFields({
required this.rowIndex,
required this.row,
required this.onChanged,
this.onRemove,
super.key,
});
/// Position of this row, used to build stable field keys.
final int rowIndex;
/// Row whose fields are rendered and mutated in place.
final _BreakUpRow row;
/// Invoked after any field in this row changes.
final VoidCallback onChanged;
/// Invoked when this row should be removed. Null when removal is disabled.
final VoidCallback? onRemove;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: TextField(
key: ValueKey('break-up-row-$rowIndex-title'),
controller: row.titleController,
decoration: const InputDecoration(labelText: 'Task title'),
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 8),
SizedBox(
width: 64,
child: TextField(
key: ValueKey('break-up-row-$rowIndex-duration'),
controller: row.durationController,
decoration: const InputDecoration(labelText: 'Min'),
keyboardType: TextInputType.number,
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 8),
SizedBox(
width: 132,
child: DropdownButtonFormField<PriorityLevel?>(
key: ValueKey('break-up-row-$rowIndex-priority'),
initialValue: row.priority,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Priority'),
items: [
const DropdownMenuItem(value: null, child: Text('Unset')),
for (final level in PriorityLevel.values)
DropdownMenuItem(value: level, child: Text(level.name)),
],
onChanged: (value) {
row.priority = value;
onChanged();
},
),
),
const SizedBox(width: 8),
SizedBox(
width: 132,
child: DropdownButtonFormField<RewardLevel>(
key: ValueKey('break-up-row-$rowIndex-reward'),
initialValue: row.reward,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Reward'),
items: [
for (final level in RewardLevel.values)
DropdownMenuItem(value: level, child: Text(level.name)),
],
onChanged: (value) {
if (value == null) {
return;
}
row.reward = value;
onChanged();
},
),
),
if (onRemove != null)
IconButton(
tooltip: 'Remove row',
onPressed: onRemove,
icon: const Icon(Icons.close),
),
],
),
);
}
}

View file

@ -0,0 +1,317 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Renders the shared Settings dialog for the FocusFlow Flutter UI.
library;
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../../theme/focus_flow_tokens.dart';
/// Time zone codes recognized by the app's runtime config loader.
const _timeZoneOptions = <String>[
'UTC',
'GMT',
'PST',
'PDT',
'MST',
'MDT',
'CST',
'CDT',
'EST',
'EDT',
];
/// Dialog that surfaces persisted [OwnerSettings] fields for editing.
///
/// Returns an [OwnerSettingsUpdate] patch containing only the fields the user
/// changed when confirmed, or `null` when cancelled.
class SettingsDialog extends StatefulWidget {
/// Creates a settings dialog seeded with the currently persisted [initial]
/// settings.
const SettingsDialog({required this.initial, super.key});
/// Currently persisted owner settings shown as the dialog's starting
/// values.
final OwnerSettings initial;
/// Creates the mutable state object used by this stateful widget.
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override
State<SettingsDialog> createState() => _SettingsDialogState();
}
/// Private implementation type for `_SettingsDialogState` in this library.
class _SettingsDialogState extends State<SettingsDialog> {
late String _timeZoneId;
late WallTime _dayStart;
late WallTime _dayEnd;
late bool _compactModeEnabled;
late final TextEditingController _freshDaysController;
late final TextEditingController _agingDaysController;
/// Validation message for the day window, if any.
String? _dayWindowError;
/// Validation message for the staleness thresholds, if any.
String? _stalenessError;
/// Initializes state owned by this object before the first build.
/// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
@override
void initState() {
super.initState();
final initial = widget.initial;
_timeZoneId = _timeZoneOptions.contains(initial.timeZoneId.toUpperCase())
? initial.timeZoneId.toUpperCase()
: _timeZoneOptions.first;
_dayStart = initial.dayStart;
_dayEnd = initial.dayEnd;
_compactModeEnabled = initial.compactModeEnabled;
_freshDaysController = TextEditingController(
text: initial.backlogStalenessSettings.greenMaxAge.inDays.toString(),
);
_agingDaysController = TextEditingController(
text: initial.backlogStalenessSettings.blueMaxAge.inDays.toString(),
);
}
/// Releases resources owned by this object before it leaves the tree.
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
@override
void dispose() {
_freshDaysController.dispose();
_agingDaysController.dispose();
super.dispose();
}
Future<void> _pickDayStart() async {
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: _dayStart.hour, minute: _dayStart.minute),
);
if (picked == null) {
return;
}
setState(() {
_dayStart = WallTime(hour: picked.hour, minute: picked.minute);
_dayWindowError = null;
});
}
Future<void> _pickDayEnd() async {
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay(hour: _dayEnd.hour, minute: _dayEnd.minute),
);
if (picked == null) {
return;
}
setState(() {
_dayEnd = WallTime(hour: picked.hour, minute: picked.minute);
_dayWindowError = null;
});
}
void _submit() {
if (_dayStart.compareTo(_dayEnd) >= 0) {
setState(() => _dayWindowError = 'Day end must be after day start.');
return;
}
final freshDays = int.tryParse(_freshDaysController.text.trim());
final agingDays = int.tryParse(_agingDaysController.text.trim());
if (freshDays == null ||
agingDays == null ||
freshDays < 0 ||
agingDays < freshDays) {
setState(
() => _stalenessError =
'Aging must be a number of days at least as large as Fresh.',
);
return;
}
Navigator.of(context).pop(
OwnerSettingsUpdate(
timeZoneId: _timeZoneId,
dayStart: _dayStart,
dayEnd: _dayEnd,
compactModeEnabled: _compactModeEnabled,
backlogStalenessSettings: BacklogStalenessSettings(
greenMaxAge: Duration(days: freshDays),
blueMaxAge: Duration(days: agingDays),
),
),
);
}
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: FocusFlowTokens.elevatedPanel,
title: const Text(
'Settings',
style: TextStyle(color: FocusFlowTokens.textPrimary),
),
content: SizedBox(
width: 420,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Time & day',
style: TextStyle(
color: FocusFlowTokens.textPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
key: const ValueKey('settings-timezone'),
initialValue: _timeZoneId,
decoration: const InputDecoration(labelText: 'Time zone'),
items: [
for (final zone in _timeZoneOptions)
DropdownMenuItem(value: zone, child: Text(zone)),
],
onChanged: (value) {
if (value == null) {
return;
}
setState(() => _timeZoneId = value);
},
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
key: const ValueKey('settings-day-start'),
onPressed: _pickDayStart,
child: Text('Day start: ${_dayStart.formatted}'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
key: const ValueKey('settings-day-end'),
onPressed: _pickDayEnd,
child: Text('Day end: ${_dayEnd.formatted}'),
),
),
],
),
if (_dayWindowError != null) ...[
const SizedBox(height: 6),
Text(
_dayWindowError!,
style: const TextStyle(color: FocusFlowTokens.accentMagenta),
),
],
const SizedBox(height: 20),
const Text(
'Compact mode',
style: TextStyle(
color: FocusFlowTokens.textPrimary,
fontWeight: FontWeight.w700,
),
),
SwitchListTile(
key: const ValueKey('settings-compact-mode'),
contentPadding: EdgeInsets.zero,
title: const Text(
'Compact Today view',
style: TextStyle(color: FocusFlowTokens.textPrimary),
),
subtitle: const Text(
'Saved now; visual toggle is coming soon.',
style: TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 12,
),
),
value: _compactModeEnabled,
onChanged: (value) {
setState(() => _compactModeEnabled = value);
},
),
const SizedBox(height: 20),
const Text(
'Backlog freshness',
style: TextStyle(
color: FocusFlowTokens.textPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
key: const ValueKey('settings-fresh-days'),
controller: _freshDaysController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Fresh (days)',
),
onChanged: (_) => setState(() => _stalenessError = null),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
key: const ValueKey('settings-aging-days'),
controller: _agingDaysController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Aging (days)',
),
onChanged: (_) => setState(() => _stalenessError = null),
),
),
],
),
const SizedBox(height: 4),
const Text(
'Anything older than Aging is shown as Stale.',
style: TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 12,
),
),
if (_stalenessError != null) ...[
const SizedBox(height: 6),
Text(
_stalenessError!,
style: const TextStyle(color: FocusFlowTokens.accentMagenta),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
key: const ValueKey('settings-save'),
onPressed: _submit,
child: const Text('Save'),
),
],
);
}
}
/// Formats a [WallTime] as `HH:MM`.
extension on WallTime {
String get formatted =>
'${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
}

View file

@ -6,12 +6,23 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/navigation/focus_flow_section.dart';
import '../theme/focus_flow_tokens.dart'; import '../theme/focus_flow_tokens.dart';
/// Static sidebar for primary FocusFlow navigation. /// Sidebar for primary FocusFlow navigation.
class Sidebar extends StatelessWidget { class Sidebar extends StatelessWidget {
/// Creates the FocusFlow sidebar. /// Creates the FocusFlow sidebar.
const Sidebar({super.key}); const Sidebar({
required this.activeSection,
required this.onSectionSelected,
super.key,
});
/// Currently active app section.
final FocusFlowSection activeSection;
/// Callback invoked when a section is selected.
final ValueChanged<FocusFlowSection> onSectionSelected;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@ -29,19 +40,44 @@ class Sidebar extends StatelessWidget {
_NavItem( _NavItem(
label: 'Today', label: 'Today',
icon: Icons.calendar_today, icon: Icons.calendar_today,
active: true, section: FocusFlowSection.today,
onTap: () {}, active: activeSection == FocusFlowSection.today,
onTap: onSectionSelected,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_NavItem(label: 'Backlog', icon: Icons.layers, onTap: () {}), _NavItem(
label: 'Backlog',
icon: Icons.layers,
section: FocusFlowSection.backlog,
active: activeSection == FocusFlowSection.backlog,
onTap: onSectionSelected,
),
const SizedBox(height: 16), 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), 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 SizedBox(height: 30),
const Divider(color: FocusFlowTokens.subtleBorder), const Divider(color: FocusFlowTokens.subtleBorder),
const SizedBox(height: 18), const SizedBox(height: 18),
_NavItem(label: 'Settings', icon: Icons.settings, onTap: () {}), _NavItem(
label: 'Settings',
icon: Icons.settings,
section: FocusFlowSection.settings,
active: activeSection == FocusFlowSection.settings,
onTap: onSectionSelected,
),
], ],
), ),
), ),
@ -97,6 +133,7 @@ class _NavItem extends StatelessWidget {
const _NavItem({ const _NavItem({
required this.label, required this.label,
required this.icon, required this.icon,
required this.section,
required this.onTap, required this.onTap,
this.active = false, this.active = false,
}); });
@ -109,9 +146,13 @@ class _NavItem extends StatelessWidget {
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final IconData icon; 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. /// 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. /// 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. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
@ -127,9 +168,11 @@ class _NavItem extends StatelessWidget {
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
key: active ? const ValueKey('nav-today-active') : null, key: ValueKey(
active ? 'nav-${section.name}-active' : 'nav-${section.name}',
),
borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius), borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius),
onTap: onTap, onTap: () => onTap(section),
child: Container( child: Container(
height: 54, height: 54,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),

View file

@ -7,9 +7,11 @@ library;
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../models/today_screen_models.dart'; import '../models/today_screen_models.dart';
import '../theme/focus_flow_tokens.dart'; import '../theme/focus_flow_tokens.dart';
import 'dialogs/break_up_dialog.dart';
import 'status_circle_button.dart'; import 'status_circle_button.dart';
import 'timeline/difficulty_bars.dart'; import 'timeline/difficulty_bars.dart';
import 'timeline/reward_icon.dart'; import 'timeline/reward_icon.dart';
@ -31,6 +33,7 @@ class TaskSelectionModal extends StatelessWidget {
this.onPushToTomorrow, this.onPushToTomorrow,
this.onPushToBacklog, this.onPushToBacklog,
this.onRemove, this.onRemove,
this.onBreakUp,
super.key, super.key,
}); });
@ -55,6 +58,26 @@ class TaskSelectionModal extends StatelessWidget {
/// Callback invoked when Remove is selected. /// Callback invoked when Remove is selected.
final Future<void> Function(TimelineCardModel card)? onRemove; final Future<void> Function(TimelineCardModel card)? onRemove;
/// Callback invoked with drafted child tasks after Break up is confirmed.
final Future<void> Function(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
)?
onBreakUp;
/// Opens the break-up dialog and forwards drafted child tasks to
/// [onBreakUp] when the user confirms with at least one task.
Future<void> _handleBreakUp(BuildContext context) async {
final drafts = await showDialog<List<ApplicationChildTaskDraft>>(
context: context,
builder: (_) => const BreakUpDialog(),
);
if (drafts == null || drafts.isEmpty) {
return;
}
await onBreakUp!(card, drafts);
}
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -132,8 +155,21 @@ class TaskSelectionModal extends StatelessWidget {
_ActionButton( _ActionButton(
icon: Icons.inventory_2_outlined, icon: Icons.inventory_2_outlined,
label: 'Backlog', label: 'Backlog',
onTap: onPushToBacklog == null
? null
: () {
unawaited(onPushToBacklog!(card));
},
),
_ActionButton(
icon: Icons.apps,
label: 'Break up',
onTap: onBreakUp == null
? null
: () {
unawaited(_handleBreakUp(context));
},
), ),
const _ActionButton(icon: Icons.apps, label: 'Break up'),
_ActionButton( _ActionButton(
icon: Icons.delete_outline, icon: Icons.delete_outline,
label: 'Remove', label: 'Remove',

View file

@ -19,6 +19,21 @@ class DifficultyBars extends StatelessWidget {
super.key, 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. /// Difficulty token that determines how many bars are filled.
final TimelineDifficultyIconToken difficulty; 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. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override

View file

@ -114,7 +114,7 @@ class _CardMetrics {
/// Returns the derived `actionsWidth` value for this object. /// Returns the derived `actionsWidth` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
double get actionsWidth => actionButtonSize * 3 + actionGap; double get actionsWidth => actionButtonSize * 4 + actionGap;
/// Returns the derived `pushButtonHeight` value for this object. /// Returns the derived `pushButtonHeight` value for this object.
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.

View file

@ -124,3 +124,55 @@ class _QuickPushIcon extends StatelessWidget {
); );
} }
} }
/// Private implementation type for `_QuickBreakUpIcon` in this library.
/// It exposes the same break-up dialog flow as the task selection modal from
/// the timeline card's compact hover actions.
class _QuickBreakUpIcon extends StatelessWidget {
/// Creates a `_QuickBreakUpIcon` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _QuickBreakUpIcon({
required this.card,
required this.color,
required this.metrics,
this.onBreakUp,
});
/// Stores the `card` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final TimelineCardModel card;
/// Stores the `color` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Color color;
/// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _CardMetrics metrics;
/// Callback invoked when Break up is selected.
final Future<void> Function()? onBreakUp;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override
Widget build(BuildContext context) {
final enabled = onBreakUp != null;
return Tooltip(
message: 'Break up',
child: Semantics(
button: true,
enabled: enabled,
label: 'Break up ${card.title}',
child: GestureDetector(
onTap: enabled ? () => unawaited(onBreakUp!()) : null,
behavior: HitTestBehavior.opaque,
child: SizedBox.square(
dimension: metrics.actionButtonSize,
child: Icon(Icons.apps, color: color, size: metrics.actionIconSize),
),
),
),
);
}
}

View file

@ -18,6 +18,7 @@ class _QuickActions extends StatelessWidget {
this.onPushToNext, this.onPushToNext,
this.onPushToTomorrow, this.onPushToTomorrow,
this.onPushToBacklog, this.onPushToBacklog,
this.onBreakUp,
}); });
/// Stores the `card` value for this object. /// Stores the `card` value for this object.
@ -53,6 +54,9 @@ class _QuickActions extends StatelessWidget {
/// Callback invoked when Push to backlog is selected. /// Callback invoked when Push to backlog is selected.
final Future<void> Function()? onPushToBacklog; final Future<void> Function()? onPushToBacklog;
/// Callback invoked when Break up is selected.
final Future<void> Function()? onBreakUp;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -90,6 +94,20 @@ class _QuickActions extends StatelessWidget {
onPushToTomorrow: onPushToTomorrow, onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog, onPushToBacklog: onPushToBacklog,
), ),
if (onBreakUp != null)
_QuickBreakUpIcon(
card: card,
color: color,
metrics: metrics,
onBreakUp: onBreakUp,
)
else
_NoOpIcon(
icon: Icons.apps,
tooltip: 'Break up',
color: color,
metrics: metrics,
),
_NoOpIcon( _NoOpIcon(
icon: Icons.calendar_today, icon: Icons.calendar_today,
tooltip: 'Schedule', tooltip: 'Schedule',

View file

@ -62,6 +62,9 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
onPushToBacklog: widget.onPushToBacklog == null onPushToBacklog: widget.onPushToBacklog == null
? null ? null
: () => _runPush(widget.onPushToBacklog!), : () => _runPush(widget.onPushToBacklog!),
onBreakUp: widget.onBreakUpCard == null
? null
: () => _handleBreakUp(context),
), ),
), ),
], ],
@ -111,6 +114,25 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
); );
} }
/// Opens the break-up dialog and forwards drafted child tasks to
/// [TaskTimelineCard.onBreakUpCard] when the user confirms with at least
/// one task.
Future<void> _handleBreakUp(BuildContext context) async {
final drafts = await showDialog<List<ApplicationChildTaskDraft>>(
context: context,
builder: (_) => const BreakUpDialog(),
);
if (drafts == null || drafts.isEmpty) {
return;
}
logger.debug(
() =>
'Card break-up requested. cardId=${widget.card.id} '
'childCount=${drafts.length}',
);
await widget.onBreakUpCard!(widget.card, drafts);
}
/// Runs the `_runPush` operation and completes after its asynchronous work finishes. /// Runs the `_runPush` operation and completes after its asynchronous work finishes.
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
Future<void> _runPush( Future<void> _runPush(

View file

@ -17,6 +17,7 @@ class _TrailingControls extends StatelessWidget {
this.onPushToNext, this.onPushToNext,
this.onPushToTomorrow, this.onPushToTomorrow,
this.onPushToBacklog, this.onPushToBacklog,
this.onBreakUp,
}); });
/// Stores the `card` value for this object. /// Stores the `card` value for this object.
@ -48,6 +49,9 @@ class _TrailingControls extends StatelessWidget {
/// Callback invoked when Push to backlog is selected. /// Callback invoked when Push to backlog is selected.
final Future<void> Function()? onPushToBacklog; final Future<void> Function()? onPushToBacklog;
/// Callback invoked when Break up is selected.
final Future<void> Function()? onBreakUp;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -66,6 +70,7 @@ class _TrailingControls extends StatelessWidget {
onPushToNext: onPushToNext, onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow, onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog, onPushToBacklog: onPushToBacklog,
onBreakUp: onBreakUp,
), ),
if (card.showPastTaskPushButton) ...[ if (card.showPastTaskPushButton) ...[
_PastTaskPushButton( _PastTaskPushButton(

View file

@ -8,10 +8,12 @@ import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart' show logger; import 'package:scheduler_core/scheduler_core.dart'
show ApplicationChildTaskDraft, logger;
import '../../models/today_screen_models.dart'; import '../../models/today_screen_models.dart';
import '../../theme/focus_flow_tokens.dart'; import '../../theme/focus_flow_tokens.dart';
import '../dialogs/break_up_dialog.dart';
import '../status_circle_button.dart'; import '../status_circle_button.dart';
import 'difficulty_bars.dart'; import 'difficulty_bars.dart';
import 'reward_icon.dart'; import 'reward_icon.dart';
@ -37,6 +39,7 @@ class TaskTimelineCard extends StatefulWidget {
this.onPushToNext, this.onPushToNext,
this.onPushToTomorrow, this.onPushToTomorrow,
this.onPushToBacklog, this.onPushToBacklog,
this.onBreakUpCard,
super.key, super.key,
}); });
@ -58,6 +61,13 @@ class TaskTimelineCard extends StatefulWidget {
/// Callback invoked when the card requests Push to backlog. /// Callback invoked when the card requests Push to backlog.
final Future<void> Function(TimelineCardModel card)? onPushToBacklog; final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
/// Callback invoked with drafted child tasks after Break up is confirmed.
final Future<void> Function(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
)?
onBreakUpCard;
/// Creates the mutable state object used by this stateful widget. /// 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. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override @override

View file

@ -6,6 +6,8 @@ library;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart'
show ApplicationChildTaskDraft;
import '../../models/today_screen_models.dart'; import '../../models/today_screen_models.dart';
import '../../theme/focus_flow_tokens.dart'; import '../../theme/focus_flow_tokens.dart';
@ -29,6 +31,7 @@ class TimelineView extends StatefulWidget {
this.onPushToNext, this.onPushToNext,
this.onPushToTomorrow, this.onPushToTomorrow,
this.onPushToBacklog, this.onPushToBacklog,
this.onBreakUpCard,
this.now, this.now,
this.scrollTargetCardId, this.scrollTargetCardId,
this.scrollRequest = 0, this.scrollRequest = 0,
@ -60,6 +63,14 @@ class TimelineView extends StatefulWidget {
/// Callback invoked when a task card requests Push to backlog. /// Callback invoked when a task card requests Push to backlog.
final Future<void> Function(TimelineCardModel card)? onPushToBacklog; final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
/// Callback invoked with drafted child tasks after a task card's Break up
/// action is confirmed.
final Future<void> Function(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
)?
onBreakUpCard;
/// Clock used to center the initial scroll position. /// Clock used to center the initial scroll position.
final DateTime Function()? now; final DateTime Function()? now;

View file

@ -148,6 +148,7 @@ class _TimelineViewState extends State<TimelineView> {
onPushToNext: widget.onPushToNext, onPushToNext: widget.onPushToNext,
onPushToTomorrow: widget.onPushToTomorrow, onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog, onPushToBacklog: widget.onPushToBacklog,
onBreakUpCard: widget.onBreakUpCard,
), ),
), ),
), ),

View file

@ -22,6 +22,7 @@ class TopBar extends StatelessWidget {
this.onNextDate, this.onNextDate,
this.onDatePressed, this.onDatePressed,
this.onDateLabelPressed, this.onDateLabelPressed,
this.onOpenSettings,
super.key, super.key,
}); });
@ -40,6 +41,9 @@ class TopBar extends StatelessWidget {
/// Callback invoked when the displayed date label is activated. /// Callback invoked when the displayed date label is activated.
final VoidCallback? onDateLabelPressed; final VoidCallback? onDateLabelPressed;
/// Callback invoked when the Settings button is activated.
final VoidCallback? onOpenSettings;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -130,7 +134,7 @@ class TopBar extends StatelessWidget {
const Spacer(), const Spacer(),
_ModeToggle(metrics: metrics), _ModeToggle(metrics: metrics),
SizedBox(width: metrics.settingsGap), SizedBox(width: metrics.settingsGap),
_SettingsButton(metrics: metrics), _SettingsButton(metrics: metrics, onPressed: onOpenSettings),
], ],
), ),
); );

View file

@ -8,18 +8,22 @@ part of '../top_bar.dart';
class _SettingsButton extends StatelessWidget { class _SettingsButton extends StatelessWidget {
/// Creates a `_SettingsButton` instance with the values required by its invariants. /// Creates a `_SettingsButton` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const _SettingsButton({required this.metrics}); const _SettingsButton({required this.metrics, this.onPressed});
/// Stores the `metrics` value for this object. /// Stores the `metrics` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _TopBarMetrics metrics; final _TopBarMetrics metrics;
/// Callback invoked when the button is activated.
final VoidCallback? onPressed;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OutlinedButton( return OutlinedButton(
onPressed: () {}, key: const ValueKey('top-bar-settings-button'),
onPressed: onPressed,
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary, foregroundColor: FocusFlowTokens.textPrimary,
side: BorderSide( side: BorderSide(

View file

@ -77,10 +77,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: drift name: drift
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c" sha256: c21b9af62e78f86837638dda6c33506bd9444ca8a32cad2b99c07369cf9e2462
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.34.0" version: "2.34.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: 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,115 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Tests app-level settings dialog behavior.
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scheduler_core/scheduler_core.dart';
import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart';
import 'package:focus_flow_flutter/app/focus_flow_app.dart';
/// Runs app-level settings dialog tests.
void main() {
testWidgets('sidebar Settings opens a dialog seeded with persisted values', (
tester,
) async {
await _pumpPlanApp(tester);
await tester.tap(find.byKey(const ValueKey('nav-settings')));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(find.text('UTC'), findsOneWidget);
final compactSwitch = tester.widget<SwitchListTile>(
find.byKey(const ValueKey('settings-compact-mode')),
);
expect(compactSwitch.value, isTrue);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('top bar Settings button opens the same dialog', (tester) async {
await _pumpPlanApp(tester);
await tester.tap(find.byKey(const ValueKey('top-bar-settings-button')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('settings-timezone')), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
});
testWidgets('saving settings persists the change and reopens with it', (
tester,
) async {
await _pumpPlanApp(tester);
await tester.tap(find.byKey(const ValueKey('nav-settings')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('settings-timezone')));
await tester.pumpAndSettle();
await tester.tap(find.text('EST').last);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('settings-save')));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
await tester.tap(find.byKey(const ValueKey('nav-settings')));
await tester.pumpAndSettle();
expect(find.text('EST'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
});
testWidgets('invalid staleness thresholds show a calm inline error', (
tester,
) async {
await _pumpPlanApp(tester);
await tester.tap(find.byKey(const ValueKey('nav-settings')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('settings-aging-days')),
'1',
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('settings-save')));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
expect(
find.text('Aging must be a number of days at least as large as Fresh.'),
findsOneWidget,
);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
});
}
/// Pumps the FocusFlow app at the desktop test size.
Future<void> _pumpPlanApp(WidgetTester tester) async {
await tester.binding.setSurfaceSize(const Size(1586, 992));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(FocusFlowApp(composition: _composition()));
await tester.pumpAndSettle();
}
/// Builds the seeded demo composition used by settings dialog tests.
DemoSchedulerComposition _composition() {
return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20));
}

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

@ -552,6 +552,7 @@ SchedulerCommandController _commandController(
}) { }) {
return SchedulerCommandController( return SchedulerCommandController(
commands: composition.commandUseCases, commands: composition.commandUseCases,
management: composition.managementUseCases,
contextFor: composition.context, contextFor: composition.context,
selectedDate: () => selectedDate, selectedDate: () => selectedDate,
refreshReads: () async {}, refreshReads: () async {},

View file

@ -41,6 +41,34 @@ void main() {
expect(find.byKey(const ValueKey('nav-today-active')), findsOneWidget); 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', ( testWidgets('seeded Today renders backend-backed compact mockup data', (
tester, tester,
) async { ) async {
@ -96,51 +124,64 @@ void main() {
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5); expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
}); });
testWidgets('task modal opens closes and keeps compact actions inert', ( testWidgets(
tester, 'task modal opens and closes, and Break up opens a cancellable dialog',
) async { (tester) async {
await _pumpPlanApp(tester); await _pumpPlanApp(tester);
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
await _scrollIntoView(tester, const ValueKey('pay-bill')); await _scrollIntoView(tester, const ValueKey('pay-bill'));
await tester.tap(find.byKey(const ValueKey('pay-bill'))); await tester.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(
expect(find.text('Pay bill'), findsNWidgets(2)); find.byKey(const ValueKey('task-selection-modal')),
expect(find.textContaining('Required'), findsWidgets); findsOneWidget,
expect(find.byTooltip('Reward level 2'), findsOneWidget); );
expect(find.byTooltip('Medium effort'), findsOneWidget); expect(find.text('Pay bill'), findsNWidgets(2));
expect(find.byTooltip('Mark complete'), findsNothing); expect(find.textContaining('Required'), findsWidgets);
expect(find.text('Reward level 2'), findsNothing); expect(find.byTooltip('Reward level 2'), findsOneWidget);
expect(find.text('Medium effort'), findsNothing); expect(find.byTooltip('Medium effort'), findsOneWidget);
expect(find.text('Done'), findsNothing); expect(find.byTooltip('Mark complete'), findsNothing);
expect(find.text('Push'), findsWidgets); expect(find.text('Reward level 2'), findsNothing);
expect(find.text('Move to Backlog'), findsNothing); expect(find.text('Medium effort'), findsNothing);
expect(find.text('Backlog'), findsWidgets); expect(find.text('Done'), findsNothing);
expect(find.text('Break up'), findsOneWidget); expect(find.text('Push'), findsWidgets);
expect(find.text('Move to Backlog'), findsNothing);
expect(find.text('Backlog'), findsWidgets);
expect(find.text('Break up'), findsOneWidget);
await tester.tap(find.text('Break up')); await tester.tap(find.text('Break up'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); expect(find.text('Break up task'), findsOneWidget);
expect(find.text('Pay bill'), findsNWidgets(2)); expect(find.byKey(const ValueKey('break-up-confirm')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('close-task-modal'))); await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); expect(
find.byKey(const ValueKey('task-selection-modal')),
findsOneWidget,
);
expect(find.text('Pay bill'), findsNWidgets(2));
await _scrollIntoView(tester, const ValueKey('pay-bill')); await tester.tap(find.byKey(const ValueKey('close-task-modal')));
await tester.tap(find.byKey(const ValueKey('pay-bill'))); await tester.pumpAndSettle();
await tester.pumpAndSettle();
await tester.tapAt(const Offset(1450, 140));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(find.text('Pay bill'), findsOneWidget);
}); await _scrollIntoView(tester, const ValueKey('pay-bill'));
await tester.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(1450, 140));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(find.text('Pay bill'), findsOneWidget);
},
);
testWidgets('task modal Push exposes destination labels and callbacks', ( testWidgets('task modal Push exposes destination labels and callbacks', (
tester, tester,
@ -870,6 +911,51 @@ void main() {
await gesture.removePointer(); await gesture.removePointer();
}); });
testWidgets(
'hover Break up icon opens the same break-up dialog as the task modal',
(tester) async {
TimelineCardModel? brokenUpCard;
List<ApplicationChildTaskDraft>? brokenUpChildren;
await _pumpTimelineCard(
tester,
card: _timelineCard(id: 'quick-break-up', title: 'Current task'),
size: const Size(420, 88),
onBreakUpCard: (card, children) async {
brokenUpCard = card;
brokenUpChildren = children;
},
);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(
location: tester.getCenter(
find.byKey(const ValueKey('quick-break-up')),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.apps));
await tester.pumpAndSettle();
expect(find.text('Break up task'), findsOneWidget);
await tester.enterText(
find.byKey(const ValueKey('break-up-row-0-title')),
'Child task',
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('break-up-confirm')));
await tester.pumpAndSettle();
expect(brokenUpCard?.id, 'quick-break-up');
expect(brokenUpChildren, hasLength(1));
expect(brokenUpChildren!.single.title, 'Child task');
await gesture.removePointer();
},
);
testWidgets('Push menu dismisses on outside click without callback', ( testWidgets('Push menu dismisses on outside click without callback', (
tester, tester,
) async { ) async {
@ -1223,6 +1309,11 @@ Future<void> _pumpTimelineCard(
Future<void> Function(TimelineCardModel card)? onPushToNext, Future<void> Function(TimelineCardModel card)? onPushToNext,
Future<void> Function(TimelineCardModel card)? onPushToTomorrow, Future<void> Function(TimelineCardModel card)? onPushToTomorrow,
Future<void> Function(TimelineCardModel card)? onPushToBacklog, Future<void> Function(TimelineCardModel card)? onPushToBacklog,
Future<void> Function(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
)?
onBreakUpCard,
}) async { }) async {
await tester.binding.setSurfaceSize( await tester.binding.setSurfaceSize(
Size(size.width + 120, size.height + 220), Size(size.width + 120, size.height + 220),
@ -1242,6 +1333,7 @@ Future<void> _pumpTimelineCard(
onPushToNext: onPushToNext, onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow, onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog, onPushToBacklog: onPushToBacklog,
onBreakUpCard: onBreakUpCard,
), ),
), ),
), ),

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. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
scheduleBacklogItemToNextAvailableSlot, 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. /// 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. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
pushFlexibleToNextAvailableSlot, 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. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
moveFlexibleToBacklog, 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. /// 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. /// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
removeTask, 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. /// 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. /// 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) { bool _taskChanged(Task before, Task after) {
return before.title != after.title || return before.title != after.title ||
before.projectId != after.projectId || before.projectId != after.projectId ||
@ -243,11 +247,22 @@ bool _taskChanged(Task before, Task after) {
!_sameNullableInstant(before.actualStart, after.actualStart) || !_sameNullableInstant(before.actualStart, after.actualStart) ||
!_sameNullableInstant(before.actualEnd, after.actualEnd) || !_sameNullableInstant(before.actualEnd, after.actualEnd) ||
!_sameNullableInstant(before.completedAt, after.completedAt) || !_sameNullableInstant(before.completedAt, after.completedAt) ||
!_sameNullableInstant(before.backlogEnteredAt, after.backlogEnteredAt) ||
before.backlogEnteredAtProvenance != after.backlogEnteredAtProvenance ||
before.parentTaskId != after.parentTaskId || before.parentTaskId != after.parentTaskId ||
!_sameBacklogTags(before.backlogTags, after.backlogTags) ||
before.reminderOverride != after.reminderOverride ||
!_sameNullableInstant(before.createdAt, after.createdAt) ||
!_sameNullableInstant(before.updatedAt, after.updatedAt) || !_sameNullableInstant(before.updatedAt, after.updatedAt) ||
before.stats != after.stats; 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. /// 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. /// 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) { 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. /// Push a planned flexible task later in the same local day.
Future<ApplicationResult<ApplicationCommandResult>> Future<ApplicationResult<ApplicationCommandResult>>
pushFlexibleToNextAvailableSlot({ 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. /// Permanently remove a task from persistence.
Future<ApplicationResult<ApplicationCommandResult>> removeTask({ Future<ApplicationResult<ApplicationCommandResult>> removeTask({
required ApplicationOperationContext context, required ApplicationOperationContext context,

View file

@ -18,17 +18,34 @@ import '../domain/models.dart';
import '../domain/project_statistics.dart'; import '../domain/project_statistics.dart';
import '../domain/time_contracts.dart'; import '../domain/time_contracts.dart';
import '../logging/focus_flow_logger.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/application_management_command_code.dart';
part 'application_management/codes/owner_settings_warning_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_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_item_read_model.dart';
part 'application_management/read_models/backlog_query_result.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/requests/get_project_defaults_request.dart';
part 'application_management/read_models/project_defaults_query_result.dart'; part 'application_management/read_models/project_defaults_query_result.dart';
part 'application_management/drafts/project_profile_draft.dart'; part 'application_management/drafts/project_profile_draft.dart';
part 'application_management/updates/project_profile_update.dart'; part 'application_management/updates/project_profile_update.dart';
part 'application_management/drafts/locked_block_draft.dart'; part 'application_management/drafts/locked_block_draft.dart';
part 'application_management/updates/locked_block_update.dart'; part 'application_management/updates/locked_block_update.dart';
part 'application_management/requests/get_owner_settings_request.dart';
part 'application_management/results/owner_settings_update_result.dart'; part 'application_management/results/owner_settings_update_result.dart';
part 'application_management/updates/owner_settings_update.dart'; part 'application_management/updates/owner_settings_update.dart';
part 'application_management/results/notice_acknowledgement_result.dart'; part 'application_management/results/notice_acknowledgement_result.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

@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../../application_management.dart';
/// Owner settings query request.
class GetOwnerSettingsRequest {
/// Creates a `GetOwnerSettingsRequest` instance with the values required by its invariants.
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
const GetOwnerSettingsRequest({required this.context});
/// Read context containing owner scope and read instant.
final ApplicationOperationContext context;
}

Some files were not shown because too many files have changed in this diff Show more