forked from eva/focus-flow
Compare commits
1 commit
feat/9-12-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 84b1394f87 |
116 changed files with 429 additions and 16055 deletions
|
|
@ -1,49 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# 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''',
|
|
||||||
]
|
|
||||||
82
AGENTS.md
82
AGENTS.md
|
|
@ -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 (SQLite‑First, July 2026)
|
# AGENTS.md — Codex Project Rules (SQLite‑First, July 2026)
|
||||||
|
|
||||||
This document supersedes all previous agent rule files. Treat it as the single source of truth.
|
This document supersedes all previous agent rule files. Treat it as the single source of truth.
|
||||||
|
|
||||||
|
|
@ -10,10 +10,9 @@ This document supersedes all previous agent rule files. Treat it as the single s
|
||||||
## 0. Quick‑start for Codex
|
## 0. Quick‑start for Codex
|
||||||
|
|
||||||
* Blocks live in `Codex Documentation/Current Software Plan/`.
|
* Blocks live in `Codex Documentation/Current Software Plan/`.
|
||||||
* Work strictly in numerical order unless the active plan explicitly says otherwise.
|
* Work strictly in numerical order (Block 19 → 20 … 29).
|
||||||
* Treat each block or named plan as the primary work unit.
|
* Each block contains numbered `XHIGH` / `HIGH` chunks.
|
||||||
* New or updated plan documents must use blocks of work, implementation steps, tasks, or acceptance checks.
|
* Stop at every `BREAKPOINT` in a chunk; wait for confirmation before continuing.
|
||||||
* 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -22,13 +21,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 | Domain-only 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 | AES-256-GCM encrypted SQLite file (`Backup library`, Block 24) |
|
| Backup | AES‑256‑GCM encrypted SQLite file (`Backup library`, Block 24) |
|
||||||
| Migrations | Drift migrations with tests (Block 20, Block 26) |
|
| Migrations | Drift migrations with tests (Block 20, Block 26) |
|
||||||
|
|
||||||
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.
|
No MongoDB runtime in V1.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -36,15 +35,15 @@ SQLite is the selected V1 implementation database. Keep Drift and database detai
|
||||||
|
|
||||||
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 multi-user.
|
3. Owner scope parameter now for future multi‑user.
|
||||||
4. Adapters implement compare-and-set; core never overwrites stale revision.
|
4. Adapters implement compare‑and‑set; core never overwrites stale revision.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Notification Rules
|
## 3. Notification Rules
|
||||||
|
|
||||||
* Use `NotificationAdapter` (Block 21).
|
* Use `NotificationAdapter` (Block 21).
|
||||||
* Desktop implementation (Block 22), fake adapter for tests.
|
* Desktop implementation (Block 22), fake adapter for tests.
|
||||||
* Core never imports platform APIs directly.
|
* Core never imports platform APIs directly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -52,7 +51,7 @@ SQLite is the selected V1 implementation database. Keep Drift and database detai
|
||||||
## 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` (Block 23).
|
* Readable exports: JSON + CSV via `ExportController` (Block 23).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -65,16 +64,16 @@ SQLite is the selected V1 implementation database. Keep Drift and database detai
|
||||||
| 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 (Block 27)
|
## 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` | hot-reload 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 |
|
||||||
|
|
||||||
|
|
@ -84,48 +83,29 @@ integrated.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6.1 Code Documentation & File Hygiene
|
## 6.1 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.
|
format. New Dart files must include file-level Dartdoc library docs and Dartdoc
|
||||||
|
comments for every class, enum, enum value, constructor, method, field, and
|
||||||
All new and modified code must be commented or documented in detail while the
|
top-level declaration. When adding code, keep large feature surfaces organized
|
||||||
work is being done. Update existing comments and docs whenever functionality,
|
under descriptive subfolders instead of expanding flat top-level `src` or app
|
||||||
contracts, invariants, side effects, persistence mappings, scheduling rules, or
|
directories.
|
||||||
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 or plan from `main` on a dedicated branch named for the
|
* Start each new block from `main` on a block branch named
|
||||||
work, such as `block-20-sqlite-adapter` or `plan-backlog-tab`.
|
`block-XX-simple-name` (for example, `block-20-sqlite-adapter`).
|
||||||
* Keep all work for the active block or plan on its dedicated branch.
|
* Keep all chunk work for that block on the block branch.
|
||||||
* Commit completed work after it is implemented, documented, and locally
|
* Commit every completed chunk before moving to the next chunk or breakpoint.
|
||||||
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`).
|
||||||
* When a full plan is complete and no work remains for that plan, rerun the
|
* Do not leave completed chunk work only in the working tree.
|
||||||
required validation. If tests still pass, merge the plan branch back into
|
* When the block is complete and verified, merge the block branch back into
|
||||||
`main`.
|
`main`.
|
||||||
* CI matrix: ubuntu-latest, windows-latest, macos-latest.
|
* CI matrix: ubuntu‑latest, windows‑latest, macos‑latest.
|
||||||
* `dart analyze` and `dart test` must pass.
|
* `dart analyze` and `dart test` must pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -145,4 +125,4 @@ groups.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Last updated: 2026-07-07_
|
_Last updated: 2026-06-27_
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
<!-- 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.
|
|
||||||
|
|
@ -1,328 +0,0 @@
|
||||||
<!-- 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 Ashley’s approval.
|
|
||||||
- Core/application tests cover bucket policy, summary, detail, and read-only suggested slots.
|
|
||||||
- Flutter code still does not know scheduler internals.
|
|
||||||
|
|
||||||
## Validation after block
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dart analyze
|
|
||||||
dart test
|
|
||||||
cd apps/focus_flow_flutter
|
|
||||||
flutter analyze
|
|
||||||
flutter test
|
|
||||||
```
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
<!-- 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 32–36 px,
|
|
||||||
- dark navy/black background,
|
|
||||||
- controls height around 40–44 px,
|
|
||||||
- right summary panel around 285–300 px wide.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Backlog shell visually resembles the default mockup header/control skeleton.
|
|
||||||
- Summary panel area exists but may display placeholder data until Block 6.
|
|
||||||
- Board area exists but may display placeholder columns until Block 4.
|
|
||||||
- No white Material surfaces.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 3.5 — Implement loading/empty/error states
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Use existing `ReadStateView` patterns only if they can match the mockup. Otherwise build Backlog-specific states:
|
|
||||||
|
|
||||||
- Loading: subtle centered progress or skeleton columns.
|
|
||||||
- Empty: calm empty board with new-task CTA.
|
|
||||||
- Failure: compact error panel with retry.
|
|
||||||
|
|
||||||
Do not show blame language.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Controller state maps to UI states.
|
|
||||||
- Tests can render loading, empty, failure, and ready states.
|
|
||||||
|
|
||||||
BREAKPOINT: Stop here for visual review if the shell deviates from the default mockup header/sidebar layout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Block acceptance criteria
|
|
||||||
|
|
||||||
- Backlog nav is functional.
|
|
||||||
- Backlog shell renders without board details.
|
|
||||||
- Today screen remains functional and visually unchanged except sidebar now supports active state.
|
|
||||||
- App still passes Flutter analyze/test.
|
|
||||||
|
|
||||||
## Validation after block
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd apps/focus_flow_flutter
|
|
||||||
flutter analyze
|
|
||||||
flutter test
|
|
||||||
```
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
<!-- 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 270–300 px,
|
|
||||||
- column gap around 8–12 px,
|
|
||||||
- column border/fill tinted by bucket accent.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Four columns are visible at mockup-like desktop width.
|
|
||||||
- Board can be used at narrower desktop widths without overflow exceptions.
|
|
||||||
- Summary panel does not overlap default board state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 4.4 — Build backlog task card widget
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Create `BacklogTaskCard` with:
|
|
||||||
|
|
||||||
- leading checkbox/selection square,
|
|
||||||
- title,
|
|
||||||
- subtitle/notes preview,
|
|
||||||
- metadata row:
|
|
||||||
- duration icon and minutes,
|
|
||||||
- project color dot/name,
|
|
||||||
- priority flag/label,
|
|
||||||
- footer:
|
|
||||||
- difficulty label and five bars,
|
|
||||||
- reward label and solid reward icons.
|
|
||||||
|
|
||||||
Use existing `DifficultyBars` and reward icon widgets where possible. If existing widgets are Today-specific, extract reusable versions without breaking Today.
|
|
||||||
|
|
||||||
Difficulty requirements:
|
|
||||||
|
|
||||||
- exactly five bars,
|
|
||||||
- `veryEasy` = 1 filled,
|
|
||||||
- `easy` = 2 filled,
|
|
||||||
- `medium` = 3 filled,
|
|
||||||
- `hard` = 4 filled,
|
|
||||||
- `veryHard` = 5 filled,
|
|
||||||
- unfilled bars are outline/subtle.
|
|
||||||
|
|
||||||
Reward requirements:
|
|
||||||
|
|
||||||
- display 1–5 solid sparkle/bolt-style marks matching the mockup language,
|
|
||||||
- no line-only star outlines.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Card content is read from `BacklogBoardItemReadModel`.
|
|
||||||
- Card supports selected state with magenta outline.
|
|
||||||
- Card click selects task and opens drawer in Block 7.
|
|
||||||
- Card does not mutate task status by itself.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 4.5 — Build child preview card section
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
For tasks with child previews, render nested child rows inside the parent card, as shown under:
|
|
||||||
|
|
||||||
- `Plan next week`
|
|
||||||
- `Complete online course`
|
|
||||||
|
|
||||||
Child row content:
|
|
||||||
|
|
||||||
- checkbox-like icon,
|
|
||||||
- child title,
|
|
||||||
- optional duration at right.
|
|
||||||
|
|
||||||
Interaction for this block:
|
|
||||||
|
|
||||||
- Child rows may be non-interactive or select the parent unless a public child-detail model already exists.
|
|
||||||
- Do not implement child completion from the Backlog board unless command wiring is explicitly available and tested.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Parent card remains visually coherent with child preview rows.
|
|
||||||
- Child previews come from read model, not title parsing.
|
|
||||||
- No scheduling/child completion rules are duplicated in Flutter.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 4.6 — Add column footer Add Task buttons
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Footer content:
|
|
||||||
|
|
||||||
- `+ Add task`, colored by bucket accent.
|
|
||||||
|
|
||||||
Behavior for this block:
|
|
||||||
|
|
||||||
- Calls a callback with intended bucket.
|
|
||||||
- Actual create drawer/command wiring happens in Block 8.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Footer is visible in every column.
|
|
||||||
- Footer does not throw when pressed before Block 8 wiring; it can open a placeholder or no-op with a TODO callback in tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 4.7 — Tune board visuals against default mockup
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Tune:
|
|
||||||
|
|
||||||
- background fills,
|
|
||||||
- border opacities,
|
|
||||||
- column corner radius,
|
|
||||||
- card height,
|
|
||||||
- card vertical spacing,
|
|
||||||
- title and metadata font sizes,
|
|
||||||
- count pill styling,
|
|
||||||
- icon sizes,
|
|
||||||
- card selected/hover states.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Default board content closely matches `mockups/backlog_board_default.png`.
|
|
||||||
- Visible mockup tasks are present in the expected columns.
|
|
||||||
- No white surfaces or accidental Material default cards.
|
|
||||||
|
|
||||||
BREAKPOINT: Stop here for visual review if card density or column spacing is substantially off.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Block acceptance criteria
|
|
||||||
|
|
||||||
- Backlog board renders from read models.
|
|
||||||
- All four columns, counts, visible task cards, child previews, difficulty bars, reward icons, and add footers are present.
|
|
||||||
- Board layout is responsive enough for desktop width changes.
|
|
||||||
- No domain mutations happen from basic card rendering.
|
|
||||||
|
|
||||||
## Validation after block
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd apps/focus_flow_flutter
|
|
||||||
flutter analyze
|
|
||||||
flutter test
|
|
||||||
```
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
<!-- 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
|
|
||||||
```
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
<!-- 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:
|
|
||||||
|
|
||||||
- `0–30 min`
|
|
||||||
- `30–60 min`
|
|
||||||
- `60–120 min`
|
|
||||||
- `120+ min`
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- Use read-model percentages/counts.
|
|
||||||
- Show legend at right with count and percentage.
|
|
||||||
- Handle zero-task state gracefully.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Donut is visually similar to mockup.
|
|
||||||
- Widget does not crash for empty or all-zero distribution.
|
|
||||||
- Tests can find legend labels/counts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 6.4 — Planning tip card
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Render planning tip:
|
|
||||||
|
|
||||||
- sparkle icon,
|
|
||||||
- title `Planning tip`,
|
|
||||||
- copy: `Pick 2–4 tasks for today. Keep it realistic and leave space for focus.`
|
|
||||||
|
|
||||||
The initial tip may be static in read model. Later plans can make tips dynamic.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Tip card matches dark glass styling.
|
|
||||||
- Text uses calm product language.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 6.5 — Collapse behavior
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Implement chevron behavior:
|
|
||||||
|
|
||||||
- Expanded by default.
|
|
||||||
- Clicking collapse hides body sections and leaves a compact header or narrow rail if it fits the design.
|
|
||||||
- Collapsed state is UI-local only.
|
|
||||||
|
|
||||||
If collapse visual is not in mockup beyond chevron, simple body hide is enough.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Collapse/expand works.
|
|
||||||
- Board widens or summary area reduces only in default summary mode; drawer overlay behavior remains unchanged.
|
|
||||||
- No persisted state change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 6.6 — Tune panel visuals
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Tune:
|
|
||||||
|
|
||||||
- panel width,
|
|
||||||
- border opacity,
|
|
||||||
- header spacing,
|
|
||||||
- section dividers,
|
|
||||||
- row heights,
|
|
||||||
- donut size,
|
|
||||||
- planning tip background.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Panel closely matches `mockups/backlog_board_default.png`.
|
|
||||||
- At mockup width, panel is fully visible and does not scroll independently unless content exceeds height.
|
|
||||||
|
|
||||||
BREAKPOINT: Stop here for visual review if donut or summary spacing substantially differs from the mockup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Block acceptance criteria
|
|
||||||
|
|
||||||
- Default Backlog screen includes summary panel.
|
|
||||||
- All panel values come from read model.
|
|
||||||
- Collapse state works.
|
|
||||||
- Search/filter changes update summary values.
|
|
||||||
|
|
||||||
## Validation after block
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd apps/focus_flow_flutter
|
|
||||||
flutter analyze
|
|
||||||
flutter test
|
|
||||||
```
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
<!-- 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: 460–470 px.
|
|
||||||
- Clamp to available width minus safe left minimum for small desktop windows.
|
|
||||||
- Drawer height fills content area inside app frame.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- Do not shift columns or summary panel.
|
|
||||||
- Do not use a heavy scrim.
|
|
||||||
- Board remains visible behind/left of drawer.
|
|
||||||
- Close icon closes drawer.
|
|
||||||
- Escape key may close drawer if easy and testable.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Drawer overlays content without moving board columns.
|
|
||||||
- No layout jump occurs when drawer opens/closes.
|
|
||||||
- Drawer remains attached to right edge during window resize.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 7.3 — Drawer header
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Header content:
|
|
||||||
|
|
||||||
- bucket/status icon with accent color,
|
|
||||||
- selected task title,
|
|
||||||
- sparkle icon at right of title area if available,
|
|
||||||
- close icon button in top-right,
|
|
||||||
- subtitle/description,
|
|
||||||
- age metadata such as `Added 2h ago (Jul 3, 2024 • 6:17 AM)`.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- Metadata should be display-ready from read model or a UI formatter that only formats dates/times.
|
|
||||||
- Title wraps at sensible line count.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- `Review Q3 budget` header matches drawer mockup for seeded task.
|
|
||||||
- Close button works.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 7.4 — Notes, project, and tags sections
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Render:
|
|
||||||
|
|
||||||
1. Notes panel:
|
|
||||||
- label `Notes`,
|
|
||||||
- notes body.
|
|
||||||
2. Project selector:
|
|
||||||
- label `Project`,
|
|
||||||
- colored dot,
|
|
||||||
- project name,
|
|
||||||
- dropdown chevron.
|
|
||||||
3. Tags section:
|
|
||||||
- label `Tags`,
|
|
||||||
- tag chips,
|
|
||||||
- add button.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- If update commands exist by this block, project/tags can be editable.
|
|
||||||
- If update commands are deferred to Block 8, controls should call placeholder callbacks and not mutate state.
|
|
||||||
- Do not invent Flutter-only persisted tags.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Seeded `finance` and `review` chips display for `Review Q3 budget`.
|
|
||||||
- Empty notes/tags render calm empty states.
|
|
||||||
- Project and tag values come from detail read model.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 7.5 — Metric tiles
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Render three tiles:
|
|
||||||
|
|
||||||
1. Duration:
|
|
||||||
- clock icon,
|
|
||||||
- `60 min`,
|
|
||||||
- `Estimated`.
|
|
||||||
2. Reward:
|
|
||||||
- reward icon,
|
|
||||||
- `3/5`,
|
|
||||||
- `Satisfying` or equivalent label.
|
|
||||||
3. Difficulty:
|
|
||||||
- difficulty icon,
|
|
||||||
- `2/5`,
|
|
||||||
- `Easy` or equivalent label.
|
|
||||||
|
|
||||||
Use centralized mapping helpers for reward/difficulty numeric labels.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Tiles match drawer mockup density.
|
|
||||||
- Values update for different selected tasks.
|
|
||||||
- Missing values render `Not set` without throwing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 7.6 — Suggested slots section
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Render:
|
|
||||||
|
|
||||||
- section title `Suggested slots`,
|
|
||||||
- `View day` button,
|
|
||||||
- rows with calendar icon, date label, time range, duration, fit badge, chevron.
|
|
||||||
|
|
||||||
Rows must support:
|
|
||||||
|
|
||||||
- green `Great Fit`,
|
|
||||||
- yellow/orange `Okay`,
|
|
||||||
- future/tomorrow labels.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- Rows may set a selected suggested slot id if slot-specific scheduling is implemented in Block 8.
|
|
||||||
- Otherwise rows are preview-only and pressing primary `Schedule` uses backend next-available command.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Seeded drawer shows three suggested slot rows similar to mockup.
|
|
||||||
- Missing duration or no-fit returns a calm explanation.
|
|
||||||
- Slot rows are generated by read model/scheduler preview, not widget-only calculations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 7.7 — Drawer action buttons
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Render action buttons:
|
|
||||||
|
|
||||||
- Primary magenta `Schedule`.
|
|
||||||
- Secondary `Break Up`.
|
|
||||||
- Full-width `Push to Someday`.
|
|
||||||
- Full-width `Remove`.
|
|
||||||
|
|
||||||
Behavior in this block:
|
|
||||||
|
|
||||||
- Buttons call callbacks supplied by parent.
|
|
||||||
- Actual commands are finalized in Block 8.
|
|
||||||
- Disable only when the read model explicitly says the action is unavailable. Do not make them look disabled because wiring is pending; instead route callbacks safely.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Buttons visually match drawer mockup.
|
|
||||||
- Button presses do not throw before command wiring.
|
|
||||||
- Stable keys exist for widget tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 7.8 — Selected card styling behind drawer
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
When selected:
|
|
||||||
|
|
||||||
- card gets magenta border/outline,
|
|
||||||
- checkbox/leading control reflects selected/focused state if visually needed,
|
|
||||||
- underlying board remains scrollable if drawer does not cover that area.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- `Review Q3 budget` selected card matches drawer mockup highlight.
|
|
||||||
- Closing drawer clears selected styling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 7.9 — Drawer polish and accessibility
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Polish:
|
|
||||||
|
|
||||||
- drawer background, border, radius,
|
|
||||||
- internal section spacing,
|
|
||||||
- text sizes,
|
|
||||||
- button heights,
|
|
||||||
- hover/focus states,
|
|
||||||
- semantic labels for close and action buttons.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Drawer closely matches `mockups/backlog_task_drawer.png`.
|
|
||||||
- Drawer is keyboard reachable enough for desktop tests.
|
|
||||||
- No white surfaces.
|
|
||||||
|
|
||||||
BREAKPOINT: Stop here for visual review if drawer is not right-anchored or if it shifts the board.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Block acceptance criteria
|
|
||||||
|
|
||||||
- Clicking a task opens the right drawer.
|
|
||||||
- Drawer overlays without shifting layout.
|
|
||||||
- Drawer content is data-driven.
|
|
||||||
- Close behavior works.
|
|
||||||
- Selected card styling works.
|
|
||||||
|
|
||||||
## Validation after block
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd apps/focus_flow_flutter
|
|
||||||
flutter analyze
|
|
||||||
flutter test
|
|
||||||
```
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
<!-- 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 tab’s visible actions to application commands while preserving backend/domain authority.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Block outcome
|
|
||||||
|
|
||||||
After this block, `New Task`, `Add task`, `Schedule`, `Break Up`, `Push to Someday`, `Remove`, and metadata edits are either implemented through application commands or explicitly left as safe non-mutating placeholders with tests and TODO notes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 8.1 — Add/update task metadata command
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Add an application command/use case such as `updateBacklogTaskDetails` or general `updateTaskDetails`.
|
|
||||||
|
|
||||||
Input fields:
|
|
||||||
|
|
||||||
- task id,
|
|
||||||
- expected updated timestamp/revision if available,
|
|
||||||
- title,
|
|
||||||
- notes,
|
|
||||||
- project id,
|
|
||||||
- duration minutes,
|
|
||||||
- priority,
|
|
||||||
- reward,
|
|
||||||
- difficulty,
|
|
||||||
- tags,
|
|
||||||
- backlog tags.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- Validate task exists.
|
|
||||||
- Validate stale update guard using existing `expectedUpdatedAt` style or repository revision style.
|
|
||||||
- Preserve schedule/source-of-truth rules.
|
|
||||||
- For backlog tasks, metadata changes should not schedule the task.
|
|
||||||
- For planned tasks, do not use this command from Backlog UI.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Metadata update command is tested.
|
|
||||||
- Notes/tags/project edits persist through repository layer.
|
|
||||||
- Flutter calls command controller, not repositories.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 8.2 — New Task drawer/create flow
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Implement reusable task editor drawer/sheet for Backlog create mode.
|
|
||||||
|
|
||||||
Open from:
|
|
||||||
|
|
||||||
- top `+ New Task`,
|
|
||||||
- column header add icon,
|
|
||||||
- column footer `+ Add task`.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- title required,
|
|
||||||
- notes optional,
|
|
||||||
- project optional/default Inbox or selected bucket/project default,
|
|
||||||
- duration optional,
|
|
||||||
- priority default Medium,
|
|
||||||
- reward optional/not set,
|
|
||||||
- difficulty optional/not set,
|
|
||||||
- tags optional.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- Create through `quickCaptureToBacklog` or a new richer create command if notes/tags are required.
|
|
||||||
- Do not force scheduling.
|
|
||||||
- On success:
|
|
||||||
- close create drawer,
|
|
||||||
- refresh Backlog board,
|
|
||||||
- show quiet success text if existing command banner pattern supports it.
|
|
||||||
- Empty title validation stays inline and preserves input.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- `New Task` creates a backlog task.
|
|
||||||
- Column Add Task can preselect or hint bucket defaults without writing a fake bucket field.
|
|
||||||
- Created task appears in board after refresh according to bucket policy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 8.3 — Schedule selected backlog task
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Wire drawer `Schedule` to existing command:
|
|
||||||
|
|
||||||
```dart
|
|
||||||
scheduleBacklogItem(
|
|
||||||
taskId: selectedTaskId,
|
|
||||||
durationMinutes: optionalDurationIfEdited,
|
|
||||||
expectedUpdatedAt: selectedUpdatedAt,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- If duration is missing, focus/show duration requirement instead of discarding task.
|
|
||||||
- If scheduling succeeds:
|
|
||||||
- task leaves Backlog board if its status changes to planned,
|
|
||||||
- drawer closes,
|
|
||||||
- summary and counts refresh,
|
|
||||||
- command status shows calm success.
|
|
||||||
- If no fit/conflict:
|
|
||||||
- keep task in Backlog,
|
|
||||||
- show drawer-local or command-banner explanation.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- UI does not choose canonical time slot by itself.
|
|
||||||
- Schedule command handles stale updates.
|
|
||||||
- Tests verify success callback and failure display with fake command controller.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 8.4 — Break Up flow
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Use existing `breakUpTask` command if possible.
|
|
||||||
|
|
||||||
Minimum V1 UI:
|
|
||||||
|
|
||||||
- Pressing `Break Up` opens a focused child-task drawer/dialog.
|
|
||||||
- User can add at least two child rows with title and optional duration.
|
|
||||||
- Saving calls `breakUpTask` with `ApplicationChildTaskDraft` entries.
|
|
||||||
- On success:
|
|
||||||
- refresh board,
|
|
||||||
- selected detail updates or drawer closes,
|
|
||||||
- child previews appear under parent.
|
|
||||||
|
|
||||||
If full child-entry UI is too large for this plan, implement a safe placeholder that opens a drawer-local message: `Break-up flow is coming next` and do not mutate state. Only use placeholder if Ashley approves at breakpoint.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- No child tasks are created in Flutter-only state.
|
|
||||||
- Break-up command path is covered by tests if implemented.
|
|
||||||
|
|
||||||
BREAKPOINT: Stop before using a placeholder for Break Up. Ask Ashley whether to include full child-entry UI now or defer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 8.5 — Push to Someday command
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Add/wire application command such as `pushBacklogTaskToSomeday`.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- Task remains backlog.
|
|
||||||
- Add `BacklogTag.wishlist` or equivalent someday metadata.
|
|
||||||
- Clear any selected scheduling preview if needed.
|
|
||||||
- Refresh board so task appears in `Not Now`.
|
|
||||||
- Record task activity/statistics if existing lifecycle service supports it.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Command is tested.
|
|
||||||
- It is non-destructive.
|
|
||||||
- It does not schedule or delete the task.
|
|
||||||
- UI refreshes board and drawer content.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 8.6 — Remove/archive command
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Add/wire application command such as `removeBacklogTask`.
|
|
||||||
|
|
||||||
Assumed behavior:
|
|
||||||
|
|
||||||
- non-destructive archive from active backlog,
|
|
||||||
- prefer `TaskStatus.noLongerRelevant` unless repo conventions define `cancelled` or a separate archive flag,
|
|
||||||
- require confirmation before applying,
|
|
||||||
- close drawer on success,
|
|
||||||
- refresh board/summary.
|
|
||||||
|
|
||||||
Confirmation copy:
|
|
||||||
|
|
||||||
- Title: `Remove from Backlog?`
|
|
||||||
- Body: `This hides the task from active planning. It will not schedule anything.`
|
|
||||||
- Actions: `Cancel`, `Remove`.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Remove does not physically delete repository data unless Ashley explicitly changed the product decision.
|
|
||||||
- User can cancel confirmation.
|
|
||||||
- Success refreshes the board.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 8.7 — Project and tags inline edits
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Once update command exists:
|
|
||||||
|
|
||||||
- Project dropdown updates project id.
|
|
||||||
- Tag add button opens small input or menu.
|
|
||||||
- Tag chip can be removed if design supports it.
|
|
||||||
- Use optimistic UI only if command result can reconcile cleanly; otherwise reload after save.
|
|
||||||
|
|
||||||
If inline autosave feels too chatty, use a small `Save changes` action inside the drawer only when fields are dirty. The mockup does not show a save button, so prefer careful inline save with visible running/success/failure states.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Project/tags update through command controller.
|
|
||||||
- Stale/failure result is visible and does not lose local input.
|
|
||||||
- Tests cover project/tag command callback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 8.8 — Command state feedback
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
|
|
||||||
Add a Backlog command status pattern:
|
|
||||||
|
|
||||||
- running state near top controls or drawer action area,
|
|
||||||
- success state quiet and auto-dismissable if existing app pattern supports it,
|
|
||||||
- failure state calm and specific.
|
|
||||||
|
|
||||||
Messages:
|
|
||||||
|
|
||||||
- `Added to Backlog.`
|
|
||||||
- `Scheduled.`
|
|
||||||
- `Kept in Backlog — no fitting slot found.`
|
|
||||||
- `Moved to Someday.`
|
|
||||||
- `Removed from active Backlog.`
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- Failures do not crash screen.
|
|
||||||
- User input is not lost on validation failure.
|
|
||||||
- Command controller reloads board on success.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Block acceptance criteria
|
|
||||||
|
|
||||||
- Visible Backlog actions either work through application commands or have explicit Ashley-approved deferral.
|
|
||||||
- Create/schedule/someday/remove paths are implemented and tested.
|
|
||||||
- Break Up is implemented or explicitly deferred at its breakpoint.
|
|
||||||
- No Flutter widget mutates domain state directly.
|
|
||||||
|
|
||||||
## Validation after block
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dart analyze
|
|
||||||
dart test
|
|
||||||
cd apps/focus_flow_flutter
|
|
||||||
flutter analyze
|
|
||||||
flutter test
|
|
||||||
```
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
<!-- 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.
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<!-- 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`
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
<!-- 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.
|
|
||||||
|
|
@ -1,345 +0,0 @@
|
||||||
<!-- 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 repo’s closest existing branch convention.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
Build the desktop-first Backlog tab for FocusFlow as a real Flutter screen, not a throwaway static mockup. The final state should provide:
|
|
||||||
|
|
||||||
1. Sidebar navigation with `Backlog` active.
|
|
||||||
2. Backlog title, task count, search, filters, sort, group, board/compact toggle, settings button, and `New Task` primary action.
|
|
||||||
3. Four-column backlog board:
|
|
||||||
- `Do Next`
|
|
||||||
- `Need Time Block`
|
|
||||||
- `Break Up First`
|
|
||||||
- `Not Now`
|
|
||||||
4. Backlog task cards with project, priority, duration, difficulty bars, reward icons, child-task previews, and selection styling.
|
|
||||||
5. Right-side Backlog summary panel in the default state.
|
|
||||||
6. Right-anchored task detail drawer when a backlog card is selected.
|
|
||||||
7. Suggested slots and task actions in the drawer.
|
|
||||||
8. New task / add task entry points wired to backlog-safe capture.
|
|
||||||
9. Tests and validation that preserve the existing backend-source-of-truth rule.
|
|
||||||
|
|
||||||
This plan intentionally treats the mockups as finalized visual direction and uses the current repo architecture as implementation truth.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Source truth and constraints
|
|
||||||
|
|
||||||
Use sources in this order:
|
|
||||||
|
|
||||||
1. Repo files, especially `AGENTS.md`, package boundaries, existing app structure, and tests.
|
|
||||||
2. This plan folder.
|
|
||||||
3. The two mockups in `mockups/`.
|
|
||||||
4. Existing completed UI Plan 1 files.
|
|
||||||
5. Human documentation and UX flow documents already in the repo.
|
|
||||||
|
|
||||||
Hard constraints:
|
|
||||||
|
|
||||||
- V1 is SQLite-first. Do not add MongoDB runtime work.
|
|
||||||
- `scheduler_core` remains pure Dart and is the scheduling/application source of truth.
|
|
||||||
- Flutter must not import scheduler `src/`, Drift/SQLite adapters, notification platform packages, backup/export internals, or OS APIs directly.
|
|
||||||
- UI may own local visual selection state, menu open/closed state, scroll position, text controller state, and drawer open/closed state.
|
|
||||||
- UI must not own canonical scheduling rules, placement rules, task lifecycle rules, or persistence mutations.
|
|
||||||
- Any scheduling/task mutation must go through public scheduler application commands/controllers.
|
|
||||||
- New Dart files require file-level Dartdoc library docs plus Dartdoc for every public/private class, enum, enum value, constructor, method, field, and top-level declaration, following `AGENTS.md`.
|
|
||||||
- New project files require SPDX metadata.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Confirmed repo observations from this planning pass
|
|
||||||
|
|
||||||
The supplied repo already contains:
|
|
||||||
|
|
||||||
- Flutter app target: `apps/focus_flow_flutter/`.
|
|
||||||
- Current provisional/Plan 1 Today UI with sidebar, app shell, theme tokens, top bar, timeline cards, difficulty bars, reward icons, and selected-task modal pieces.
|
|
||||||
- Current simple `BacklogPane` and `BacklogRow` that are not visually aligned with the finalized board mockup.
|
|
||||||
- Existing public scheduler application/query concepts:
|
|
||||||
- `V1ApplicationManagementUseCases.getBacklog`.
|
|
||||||
- `BacklogQueryResult`.
|
|
||||||
- `BacklogItemReadModel`.
|
|
||||||
- `GetBacklogRequest`.
|
|
||||||
- `BacklogView`, `BacklogFilter`, and `BacklogSortKey`.
|
|
||||||
- `V1ApplicationCommandUseCases.quickCaptureToBacklog`.
|
|
||||||
- `V1ApplicationCommandUseCases.scheduleBacklogItemToNextAvailableSlot`.
|
|
||||||
- `V1ApplicationCommandUseCases.breakUpTask`.
|
|
||||||
- Existing domain `Task` supports core scheduling fields, project id, duration, priority, reward, difficulty, backlog tags, parent task id, stats, timestamps, scheduled/actual/completed intervals, status, and type.
|
|
||||||
- Current domain `Task` does **not** appear to include freeform notes or general tags, which the drawer mockup shows.
|
|
||||||
- Current application commands do **not** appear to include a general update/edit-backlog-task command, push-to-someday command, or remove/archive backlog command.
|
|
||||||
|
|
||||||
Plan implication: this is a UI implementation plus a small application/domain read-model expansion, not only a widget rewrite.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Product assumptions to implement unless Ashley changes them
|
|
||||||
|
|
||||||
These assumptions unblock Codex without inventing broad product scope:
|
|
||||||
|
|
||||||
1. **Backlog columns are a V1 read-model policy.** The Flutter board consumes a `BacklogBoardReadModel`; it does not independently decide canonical task grouping.
|
|
||||||
2. **Column assignment is deterministic and explainable.** Each item exposes `bucket`, `bucketReason`, and optional `reasonMetadata` so the UI can show why it is in that column.
|
|
||||||
3. **General tags and notes are V1 task metadata.** Add minimal domain/persistence/application support needed for the drawer and create/edit flows.
|
|
||||||
4. **Remove means archive from active backlog, not physical delete.** Use an application-level remove/archive transition that hides the task from backlog. Prefer `TaskStatus.noLongerRelevant` unless a repo-local convention already says otherwise.
|
|
||||||
5. **Push to Someday means mark the task as a someday/wishlist backlog item.** Use existing `BacklogTag.wishlist` where possible. This should move or keep the task in `Not Now`.
|
|
||||||
6. **Suggested slots are non-mutating previews.** They must be generated by application/core read logic or by calling scheduler logic against cloned/current state. They must not save anything until the user presses `Schedule`.
|
|
||||||
7. **Schedule button chooses a suggested slot only through backend intent.** If the current command only supports “next available slot,” wire the primary button to that command and label suggested rows as preview-only until a slot-specific command exists.
|
|
||||||
8. **Compact mode can remain a non-primary visual affordance.** Board mode is the acceptance target because only Board has a finalized mockup. If a low-risk compact list already exists, it may be reused behind the toggle, but do not invent a second finalized design.
|
|
||||||
9. **Settings remains visible and inert for this plan** unless a settings surface already exists.
|
|
||||||
10. **Project dropdown and tag controls in the drawer should support inline edits only after the relevant update command exists.** If the command is not completed in the same block, render them as disabled/read-only with active visual styling deferred only at the final polish pass.
|
|
||||||
|
|
||||||
Non-blocking clarification items for Ashley after plan review:
|
|
||||||
|
|
||||||
- Exact board bucket policy thresholds for `Do Next`, `Need Time Block`, `Break Up First`, and `Not Now`.
|
|
||||||
- Whether `Remove` should be reversible archive, cancellation, no-longer-relevant, or hard delete.
|
|
||||||
- Whether slot rows should support picking a specific slot in V1 or only preview the result of next-available scheduling.
|
|
||||||
- Whether freeform tags should be global reusable tags or simple task-local strings in V1.
|
|
||||||
|
|
||||||
Do not stop implementation on those questions unless Ashley explicitly requests a policy change before coding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Visual target summary
|
|
||||||
|
|
||||||
### App shell and sidebar
|
|
||||||
|
|
||||||
- Keep the dark rounded app frame and left sidebar style from UI Plan 1.
|
|
||||||
- Sidebar width remains approximately 250 px.
|
|
||||||
- `Backlog` nav item is active with magenta-tinted fill and outline.
|
|
||||||
- `Today`, `Projects`, `Reports`, and `Settings` remain visible.
|
|
||||||
- Bottom-left status card shows:
|
|
||||||
- green check icon,
|
|
||||||
- `Schedule up to date`,
|
|
||||||
- `All changes are saved.`
|
|
||||||
|
|
||||||
### Backlog default state
|
|
||||||
|
|
||||||
At desktop width around 1672×941:
|
|
||||||
|
|
||||||
- Main content has a left board region and a right summary panel.
|
|
||||||
- Header row:
|
|
||||||
- `Backlog` title,
|
|
||||||
- `24 tasks` count pill,
|
|
||||||
- subtitle: `Choose work that fits your current energy.`
|
|
||||||
- right controls: `Compact`, `Board` active, `Settings`.
|
|
||||||
- Control row:
|
|
||||||
- search field with placeholder `Search backlog...`,
|
|
||||||
- `Filters` button,
|
|
||||||
- `Sort: Custom` dropdown,
|
|
||||||
- `Group: None` dropdown,
|
|
||||||
- magenta `+ New Task` button with trailing chevron.
|
|
||||||
- Board columns:
|
|
||||||
- each column has an icon, title, count pill, subtitle, add icon, card stack, and `+ Add task` footer.
|
|
||||||
- Summary panel:
|
|
||||||
- `Backlog summary` header with sparkle icon and collapse chevron,
|
|
||||||
- total tasks and total estimated time,
|
|
||||||
- priority counts,
|
|
||||||
- project counts,
|
|
||||||
- duration distribution donut/list,
|
|
||||||
- planning tip card.
|
|
||||||
|
|
||||||
### Selected-card drawer state
|
|
||||||
|
|
||||||
- Selecting/clicking a backlog card opens a right-side drawer.
|
|
||||||
- Drawer is anchored to the right edge of the app content/window.
|
|
||||||
- Drawer overlays existing content and does **not** shift columns, header controls, or summary panel.
|
|
||||||
- No heavy scrim; underlying board remains visible.
|
|
||||||
- Selected card remains highlighted with magenta outline behind the drawer.
|
|
||||||
- Drawer width should be approximately 460–470 px at mockup width, clamped for smaller desktop windows.
|
|
||||||
- Drawer sections:
|
|
||||||
- header with bucket/status icon, title, sparkle icon, close icon,
|
|
||||||
- subtitle and age/created metadata,
|
|
||||||
- notes panel,
|
|
||||||
- project selector and tag chips,
|
|
||||||
- duration/reward/difficulty metric tiles,
|
|
||||||
- suggested slots list,
|
|
||||||
- action buttons: `Schedule`, `Break Up`, `Push to Someday`, `Remove`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Feature scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
|
|
||||||
- Backlog route/tab under current Flutter app.
|
|
||||||
- Sidebar nav state and Backlog active rendering.
|
|
||||||
- Backlog-specific controllers/read state.
|
|
||||||
- Backlog board read models in public scheduler/application API.
|
|
||||||
- Demo seed data sufficient to render the exact mockup composition.
|
|
||||||
- Four board columns with counts and card ordering.
|
|
||||||
- Default summary panel.
|
|
||||||
- Drawer open/close/select-another behavior.
|
|
||||||
- Search/filter/sort/group controls with at least deterministic basic behavior.
|
|
||||||
- New Task and Add Task entry points with backlog-safe capture.
|
|
||||||
- Schedule action for a selected backlog item.
|
|
||||||
- Break Up action path if the existing `breakUpTask` command can be surfaced safely; otherwise the drawer button may open a non-persisting placeholder form until Block 8 completes the command wiring.
|
|
||||||
- Push to Someday command using `BacklogTag.wishlist` or equivalent.
|
|
||||||
- Remove/archive command using the chosen non-destructive lifecycle transition.
|
|
||||||
- Notes and tags support needed for drawer/create/edit read and persistence.
|
|
||||||
- Responsive handling for desktop width reductions.
|
|
||||||
- Widget tests and core/application tests.
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
|
|
||||||
- Week/month views.
|
|
||||||
- Drag-and-drop card movement.
|
|
||||||
- Reports implementation.
|
|
||||||
- Shield/Recovery flows.
|
|
||||||
- Calendar sync.
|
|
||||||
- OS notifications.
|
|
||||||
- Backup/restore UI.
|
|
||||||
- Export/import UI.
|
|
||||||
- Full settings implementation.
|
|
||||||
- Perfect pixel golden tests.
|
|
||||||
- Mobile-first layout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Target data/read-model shape
|
|
||||||
|
|
||||||
Codex may adjust exact names to match repo conventions, but preserve the information architecture.
|
|
||||||
|
|
||||||
```dart
|
|
||||||
enum BacklogBoardBucket {
|
|
||||||
doNext,
|
|
||||||
needTimeBlock,
|
|
||||||
breakUpFirst,
|
|
||||||
notNow,
|
|
||||||
}
|
|
||||||
|
|
||||||
class BacklogBoardQueryResult {
|
|
||||||
final String ownerId;
|
|
||||||
final CivilDate localDate;
|
|
||||||
final BacklogBoardSummaryReadModel summary;
|
|
||||||
final List<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: `0–30`, `30–60`, `60–120`, `120+`,
|
|
||||||
- planning tip string.
|
|
||||||
|
|
||||||
Suggested slots should include:
|
|
||||||
|
|
||||||
- stable id,
|
|
||||||
- local date label,
|
|
||||||
- start/end display text,
|
|
||||||
- duration minutes,
|
|
||||||
- fit label (`Great Fit`, `Okay`, no-fit/conflict if relevant),
|
|
||||||
- fit severity token,
|
|
||||||
- whether it is the primary/default scheduling target.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Block list
|
|
||||||
|
|
||||||
Execute these files in order:
|
|
||||||
|
|
||||||
1. `UI_PLAN_2_BLOCK_01_REPO_SCOPE_AND_ASSETS.md`
|
|
||||||
2. `UI_PLAN_2_BLOCK_02_BACKLOG_READ_MODELS_AND_METADATA.md`
|
|
||||||
3. `UI_PLAN_2_BLOCK_03_NAVIGATION_AND_SCREEN_SHELL.md`
|
|
||||||
4. `UI_PLAN_2_BLOCK_04_BOARD_COLUMNS_AND_CARDS.md`
|
|
||||||
5. `UI_PLAN_2_BLOCK_05_SEARCH_FILTER_SORT_GROUP.md`
|
|
||||||
6. `UI_PLAN_2_BLOCK_06_SUMMARY_PANEL.md`
|
|
||||||
7. `UI_PLAN_2_BLOCK_07_TASK_DETAIL_DRAWER.md`
|
|
||||||
8. `UI_PLAN_2_BLOCK_08_COMMANDS_CREATE_AND_ACTIONS.md`
|
|
||||||
9. `UI_PLAN_2_BLOCK_09_TESTING_VALIDATION_HANDOFF.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Expected final changed areas
|
|
||||||
|
|
||||||
Likely changed or added areas:
|
|
||||||
|
|
||||||
```text
|
|
||||||
packages/scheduler_core/lib/src/domain/models/entities/task.dart
|
|
||||||
packages/scheduler_core/lib/src/application/application_management/**
|
|
||||||
packages/scheduler_core/lib/src/application/application_commands/**
|
|
||||||
packages/scheduler_core/lib/src/persistence/document_mapping/tasks/**
|
|
||||||
packages/scheduler_core/lib/src/persistence/persistence_contract/fields/tasks/**
|
|
||||||
packages/scheduler_persistence/lib/persistence/repositories/task_repository.dart
|
|
||||||
packages/scheduler_persistence_memory/**
|
|
||||||
packages/scheduler_persistence_sqlite/lib/src/scheduler_db/tables/tasks/tasks.dart
|
|
||||||
packages/scheduler_persistence_sqlite/lib/src/scheduler_db/daos/task_dao.dart
|
|
||||||
packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories/sqlite_task_repository.dart
|
|
||||||
apps/focus_flow_flutter/lib/app/**
|
|
||||||
apps/focus_flow_flutter/lib/controllers/**
|
|
||||||
apps/focus_flow_flutter/lib/models/backlog/**
|
|
||||||
apps/focus_flow_flutter/lib/widgets/backlog/**
|
|
||||||
apps/focus_flow_flutter/lib/widgets/sidebar.dart
|
|
||||||
apps/focus_flow_flutter/lib/theme/**
|
|
||||||
apps/focus_flow_flutter/test/**
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not modify unrelated packages unless tests prove a required boundary update.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Final acceptance criteria
|
|
||||||
|
|
||||||
The plan is complete when:
|
|
||||||
|
|
||||||
- Backlog sidebar item opens/renders the Backlog tab and is visually active.
|
|
||||||
- Default Backlog board state closely matches `backlog_board_default.png`.
|
|
||||||
- Selecting `Review Q3 budget` or any task card opens a right-side drawer matching `backlog_task_drawer.png`.
|
|
||||||
- Drawer overlays content without moving columns or summary.
|
|
||||||
- Search/filter/sort/group controls are visible and do not break board state.
|
|
||||||
- Board columns, cards, summary, and drawer are all driven by scheduler/application read models or controller-level display models derived from those read models.
|
|
||||||
- Schedule action uses scheduler application command(s); UI does not compute canonical placement.
|
|
||||||
- New task capture creates backlog tasks through scheduler application command(s).
|
|
||||||
- Push to Someday and Remove are non-destructive domain/application commands, not Flutter-only state deletion.
|
|
||||||
- Tests cover default render, navigation, controls, card selection, drawer close, summary data, and command callbacks.
|
|
||||||
- `flutter analyze` and `flutter test` pass under `apps/focus_flow_flutter`.
|
|
||||||
- Root/package tests touched by domain/persistence changes pass or are explicitly reported if environment prevents full validation.
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<!-- 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.
|
Before Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB |
|
|
@ -1,59 +1,35 @@
|
||||||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
# V1 ADR 002: SQLite Document Schema V1
|
||||||
<!-- 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 **SQLite‑first** persistence.
|
||||||
The project is SQLite-first. The local app persists scheduler state in a Drift
|
We need a stable, versioned relational schema that maps cleanly to the domain
|
||||||
database and keeps storage details behind repository and application-unit-of-work
|
objects while staying behind the repository abstraction. Drift will manage
|
||||||
boundaries.
|
migrations.
|
||||||
|
|
||||||
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.
|
||||||
* **Database file**: `scheduler.sqlite` under `~/ADHD_Scheduler/` by default.
|
* **Schema version**: `1` (managed by Drift).
|
||||||
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`, `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` |
|
| `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` |
|
||||||
| `task_activities` | Append-only task activity facts | `id TEXT PRIMARY KEY`, `owner_id`, `task_id`, `project_id`, `operation_id`, `code`, `occurred_at_utc`, `metadata_json` |
|
| `projects` | Project configuration | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `color_key`, config defaults …, `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_blocks` | Recurring / one‑off locked time | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `date TEXT`, `start_time TEXT`, `end_time TEXT`, `recurrence_json`, `hidden_by_default INT`, `archived_at_utc`, `revision` |
|
||||||
| `project_statistics` | Project-level aggregate statistics | `project_id PRIMARY KEY`, `owner_id`, completion and suggestion-count fields, `revision`, `created_at_utc`, `updated_at_utc` |
|
| `locked_overrides` | Date‑scoped overrides | `id TEXT PRIMARY KEY`, `owner_id`, `locked_block_id`, `date TEXT`, `type`, JSON fields |
|
||||||
| `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 |
|
| `settings` | One row per owner | `owner_id TEXT PRIMARY KEY`, `timezone_id`, `day_start_minutes`, `day_end_minutes`, `compact_mode INT`, `revision` |
|
||||||
| `locked_overrides` | Date-scoped locked-block overrides | `id TEXT PRIMARY KEY`, `owner_id`, `locked_block_id`, `date`, override type and payload fields |
|
| `activities` | Append‑only internal events | `id TEXT PRIMARY KEY`, `owner_id`, `task_id`, `code`, `occurred_at_utc`, `metadata_json` |
|
||||||
| `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`, `operation_name`, `source_date TEXT`, `window_json`, `notice_json`, `changes_json`, `retention_expires_utc` |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
* UTC timestamps are stored in Drift `DateTime` columns. Civil dates and wall
|
* All timestamps are stored as **UTC**.
|
||||||
times remain text fields where the domain model requires calendar-local values.
|
* Optimistic concurrency: every mutable row carries `revision`. Updates must
|
||||||
* Mutable domain/application records carry optimistic `revision` values. Updates
|
include `WHERE revision = :expected` and increment on success.
|
||||||
must compare the expected revision and increment on success. Append-only
|
* Drift migrations are generated and tested; downgrades are not supported.
|
||||||
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.
|
||||||
* Domain objects remain unchanged; adapters translate between domain objects and
|
* Backup library (Block 24) can copy and encrypt this single file.
|
||||||
SQLite rows.
|
* Repository contract tests assert schema integrity via Drift introspection.
|
||||||
* 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.
|
|
||||||
|
|
@ -1,33 +1,9 @@
|
||||||
<!-- 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; updated to match current app state**
|
Status: **Accepted**
|
||||||
|
|
||||||
Date: 2026-06-26
|
Date: 2026-06-26
|
||||||
|
|
||||||
Last reviewed: 2026-07-07
|
* **Backup** – passphrase‑encrypted SQLite copy (`.sqlite.aes` via AES‑256‑GCM, PBKDF2 200 k rounds).
|
||||||
|
* **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.
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,8 @@
|
||||||
<!-- 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; updated to match current app state**
|
Status: **Accepted**
|
||||||
|
|
||||||
Date: 2026-06-26
|
Date: 2026-06-26
|
||||||
|
|
||||||
Last reviewed: 2026-07-07
|
Persist committed diagnostic snapshots only; bounded retention 30 days or until notices acknowledged.
|
||||||
|
|
||||||
## Decision
|
No open questions remain.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,8 @@
|
||||||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
|
||||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
|
||||||
|
|
||||||
# V1 ADR 008: Notification Abstraction
|
# V1 ADR 008: Notification Abstraction
|
||||||
|
|
||||||
Status: **Accepted; updated to match current app state**
|
Status: **Accepted**
|
||||||
|
|
||||||
Date: 2026-06-26
|
Date: 2026-06-26
|
||||||
|
|
||||||
Last reviewed: 2026-07-07
|
`NotificationAdapter` interface abstracts scheduling/cancellation and feedback stream. Desktop implementation in `scheduler_notifications_desktop`; Fake adapter for tests.
|
||||||
|
|
||||||
## Decision
|
No open questions remain.
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
|
||||||
BIN
Human Documentation/FocusFlow_UX_Interaction_Flows_Dark.pptx
Normal file
BIN
Human Documentation/FocusFlow_UX_Interaction_Flows_Dark.pptx
Normal file
Binary file not shown.
13
Human Documentation/Original Chat-Compiled Design Spec.md
Normal file
13
Human Documentation/Original Chat-Compiled Design Spec.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# 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.
|
||||||
10
Human Documentation/README.md
Normal file
10
Human Documentation/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# 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/`.
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<!-- 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.
|
|
||||||
|
|
@ -11,19 +11,10 @@ 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.
|
||||||
|
|
||||||
cards. The sidebar Backlog navigation opens the board surface backed by public
|
The compact Today screen can toggle completion for currently rendered task
|
||||||
scheduler application APIs. Backlog supports the four board buckets, search,
|
cards. Backlog widgets and quick-capture controls exist for tests and focused
|
||||||
filters, sort/group controls, summary data, the right-anchored detail drawer,
|
composition paths, but the sidebar Backlog navigation remains out of scope for
|
||||||
task creation, scheduling, Break Up child-task creation, Someday tagging, and
|
this screen.
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -111,11 +102,13 @@ boundary.
|
||||||
|
|
||||||
## Intentionally No-op
|
## Intentionally No-op
|
||||||
|
|
||||||
- Compact/Normal toggle (Settings persists the flag, but the Today pipeline
|
- Sidebar navigation.
|
||||||
does not yet read it; see Forgejo issue #11).
|
- Date navigation and calendar buttons.
|
||||||
|
- Compact/Normal toggle.
|
||||||
|
- Settings button.
|
||||||
- Show upcoming.
|
- Show upcoming.
|
||||||
- Timeline card Schedule and Protect time icons.
|
- Timeline card action icons.
|
||||||
- Backlog drawer project selector, add-tag control, and View day shortcut.
|
- Modal Push, Move to Backlog, and Break up buttons.
|
||||||
|
|
||||||
## Persistence Proof
|
## Persistence Proof
|
||||||
|
|
||||||
|
|
@ -178,8 +171,7 @@ Run these from `apps/focus_flow_flutter/`.
|
||||||
|
|
||||||
## Next Plans
|
## Next Plans
|
||||||
|
|
||||||
- Give the Compact/Normal toggle real backing state (the live Today pipeline
|
- Wire visible Backlog navigation and quick-capture UI into the main shell.
|
||||||
has no compact-mode concept yet; see Forgejo issue #11).
|
- Wire functional Push, Move to Backlog, and Break up actions.
|
||||||
- Add migration-backed notes, project edits, and freeform tags for Backlog
|
- Add real navigation/settings.
|
||||||
metadata.
|
|
||||||
- Add Shield/Recovery only in a later scope.
|
- Add Shield/Recovery only in a later scope.
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,6 @@
|
||||||
|
|
||||||
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) {
|
||||||
|
|
@ -147,284 +123,6 @@ 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({
|
||||||
|
|
@ -463,34 +161,3 @@ 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ 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';
|
||||||
|
|
@ -92,13 +91,24 @@ 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 projects = _demoProjects();
|
final project = ProjectProfile(
|
||||||
|
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) ..._backlogSeedTasks(resolvedDate, createdAt),
|
if (includeBacklogItems)
|
||||||
|
Task.quickCapture(
|
||||||
|
id: 'later-backlog-note',
|
||||||
|
title: 'Review reusable grocery list',
|
||||||
|
projectId: projectId,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
final store = InMemoryApplicationUnitOfWork(
|
final store = InMemoryApplicationUnitOfWork(
|
||||||
initialProjects: projects,
|
initialProjects: [project],
|
||||||
initialOwnerSettings: [
|
initialOwnerSettings: [
|
||||||
OwnerSettings(
|
OwnerSettings(
|
||||||
ownerId: ownerId,
|
ownerId: ownerId,
|
||||||
|
|
@ -148,39 +158,6 @@ 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>(
|
||||||
|
|
@ -199,16 +176,18 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a generic read controller for the settings screen.
|
/// Creates a generic read controller for backlog panes.
|
||||||
@override
|
UiReadController<BacklogQueryResult> createBacklogController() {
|
||||||
UiReadController<OwnerSettings> createOwnerSettingsController() {
|
return ApplicationReadController<BacklogQueryResult>(
|
||||||
return ApplicationReadController<OwnerSettings>(
|
|
||||||
read: () {
|
read: () {
|
||||||
return managementUseCases.getOwnerSettings(
|
return managementUseCases.getBacklog(
|
||||||
GetOwnerSettingsRequest(context: context('ui-read-owner-settings')),
|
GetBacklogRequest(
|
||||||
|
context: context('ui-read-backlog'),
|
||||||
|
sortKey: BacklogSortKey.age,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
isEmpty: (_) => false,
|
isEmpty: (state) => state.items.isEmpty,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,7 +199,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -9,18 +9,13 @@ 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';
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,9 @@ 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 `todayController` value for this object.
|
/// Stores the `controller` 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 todayController;
|
late final TodayScreenController controller;
|
||||||
|
|
||||||
/// 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.
|
||||||
|
|
@ -22,14 +18,6 @@ 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
|
||||||
|
|
@ -37,22 +25,15 @@ 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();
|
||||||
todayController = widget.composition.createTodayScreenController(
|
controller = 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: _refreshReads,
|
refreshReads: controller.load,
|
||||||
selectedDate: () => selectedDateController.date,
|
selectedDate: () => selectedDateController.date,
|
||||||
);
|
);
|
||||||
logger.finer(() => 'FocusFlow home triggering initial Today load.');
|
logger.finer(() => 'FocusFlow home triggering initial Today load.');
|
||||||
todayController.load();
|
controller.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.
|
||||||
|
|
@ -61,9 +42,7 @@ 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();
|
||||||
backlogController.dispose();
|
controller.dispose();
|
||||||
todayController.dispose();
|
|
||||||
ownerSettingsController.dispose();
|
|
||||||
selectedDateController.dispose();
|
selectedDateController.dispose();
|
||||||
unawaited(widget.composition.dispose());
|
unawaited(widget.composition.dispose());
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -135,78 +114,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
taskId: card.id,
|
taskId: card.id,
|
||||||
expectedUpdatedAt: card.expectedUpdatedAt,
|
expectedUpdatedAt: card.expectedUpdatedAt,
|
||||||
);
|
);
|
||||||
todayController.clearSelection();
|
controller.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.
|
||||||
|
|
@ -248,80 +156,55 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: FocusFlowTokens.appBackground,
|
backgroundColor: FocusFlowTokens.appBackground,
|
||||||
body: AppShell(
|
body: AppShell(
|
||||||
sidebar: Sidebar(
|
sidebar: const Sidebar(),
|
||||||
activeSection: _activeSection,
|
child: AnimatedBuilder(
|
||||||
onSectionSelected: _selectSection,
|
animation: controller,
|
||||||
),
|
|
||||||
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, _) {
|
builder: (context, _) {
|
||||||
return switch (todayController.state) {
|
return switch (controller.state) {
|
||||||
TodayScreenLoading() => const Center(
|
TodayScreenLoading() => const Center(
|
||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
),
|
),
|
||||||
TodayScreenFailure(:final message) => _PlanIssue(message: message),
|
TodayScreenFailure(:final message) => _PlanIssue(
|
||||||
|
message: message,
|
||||||
|
),
|
||||||
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
|
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
|
||||||
data: data,
|
data: data,
|
||||||
selectedCard: todayController.selectedCard,
|
selectedCard: controller.selectedCard,
|
||||||
onSelectCard: todayController.selectCard,
|
onSelectCard: controller.selectCard,
|
||||||
onCompleteCard: _toggleCardCompletion,
|
onCompleteCard: _toggleCardCompletion,
|
||||||
onPushToNext: _pushCardToNext,
|
onPushToNext: _pushCardToNext,
|
||||||
onPushToTomorrow: _pushCardToTomorrow,
|
onPushToTomorrow: _pushCardToTomorrow,
|
||||||
onPushToBacklog: _pushCardToBacklog,
|
onPushToBacklog: _pushCardToBacklog,
|
||||||
onRemoveCard: _removeCard,
|
onRemoveCard: _removeCard,
|
||||||
onAddTask: _addGenericFlexibleTask,
|
onAddTask: _addGenericFlexibleTask,
|
||||||
onBreakUpCard: _breakUpCard,
|
onClearSelection: controller.clearSelection,
|
||||||
onClearSelection: todayController.clearSelection,
|
|
||||||
onPreviousDate: _selectPreviousDate,
|
onPreviousDate: _selectPreviousDate,
|
||||||
onNextDate: _selectNextDate,
|
onNextDate: _selectNextDate,
|
||||||
onChooseDate: () {
|
onChooseDate: () {
|
||||||
unawaited(_chooseDate());
|
unawaited(_chooseDate());
|
||||||
},
|
},
|
||||||
onOpenSettings: () {
|
|
||||||
unawaited(_openSettings());
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
TodayScreenReady(:final data) => _TodayScreenScaffold(
|
TodayScreenReady(:final data) => _TodayScreenScaffold(
|
||||||
data: data,
|
data: data,
|
||||||
selectedCard: todayController.selectedCard,
|
selectedCard: controller.selectedCard,
|
||||||
onSelectCard: todayController.selectCard,
|
onSelectCard: controller.selectCard,
|
||||||
onCompleteCard: _toggleCardCompletion,
|
onCompleteCard: _toggleCardCompletion,
|
||||||
onPushToNext: _pushCardToNext,
|
onPushToNext: _pushCardToNext,
|
||||||
onPushToTomorrow: _pushCardToTomorrow,
|
onPushToTomorrow: _pushCardToTomorrow,
|
||||||
onPushToBacklog: _pushCardToBacklog,
|
onPushToBacklog: _pushCardToBacklog,
|
||||||
onRemoveCard: _removeCard,
|
onRemoveCard: _removeCard,
|
||||||
onBreakUpCard: _breakUpCard,
|
|
||||||
onAddTask: _addGenericFlexibleTask,
|
onAddTask: _addGenericFlexibleTask,
|
||||||
onClearSelection: todayController.clearSelection,
|
onClearSelection: controller.clearSelection,
|
||||||
onPreviousDate: _selectPreviousDate,
|
onPreviousDate: _selectPreviousDate,
|
||||||
onNextDate: _selectNextDate,
|
onNextDate: _selectNextDate,
|
||||||
onChooseDate: () {
|
onChooseDate: () {
|
||||||
unawaited(_chooseDate());
|
unawaited(_chooseDate());
|
||||||
},
|
},
|
||||||
onOpenSettings: () {
|
|
||||||
unawaited(_openSettings());
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,48 +237,3 @@ 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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,11 @@ 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.
|
||||||
|
|
@ -58,14 +56,6 @@ 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;
|
||||||
|
|
@ -86,10 +76,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,6 @@ 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)
|
||||||
|
|
@ -104,7 +103,6 @@ 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),
|
||||||
),
|
),
|
||||||
|
|
@ -136,7 +134,6 @@ 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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,7 @@ 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';
|
||||||
|
|
@ -206,58 +204,6 @@ 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({
|
||||||
|
|
@ -270,7 +216,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -6,9 +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/selected_date_controller.dart';
|
import '../controllers/selected_date_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
|
|
||||||
|
|
@ -34,14 +32,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -1,552 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ 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,
|
||||||
|
|
@ -22,10 +21,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -139,116 +134,6 @@ 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,
|
||||||
|
|
@ -299,7 +184,7 @@ class SchedulerCommandController extends ChangeNotifier {
|
||||||
TaskType.locked ||
|
TaskType.locked ||
|
||||||
TaskType.surprise ||
|
TaskType.surprise ||
|
||||||
TaskType.freeSlot => Future.value(
|
TaskType.freeSlot => Future.value(
|
||||||
ApplicationResult<ApplicationCommandResult>.failure(
|
ApplicationResult.failure(
|
||||||
ApplicationFailure(
|
ApplicationFailure(
|
||||||
code: ApplicationFailureCode.validation,
|
code: ApplicationFailureCode.validation,
|
||||||
entityId: taskId,
|
entityId: taskId,
|
||||||
|
|
@ -419,59 +304,6 @@ 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,
|
||||||
|
|
@ -500,11 +332,14 @@ 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<R>({
|
Future<void> _run({
|
||||||
required String label,
|
required String label,
|
||||||
required String successMessage,
|
required String successMessage,
|
||||||
required bool refreshAfterSuccess,
|
required bool refreshAfterSuccess,
|
||||||
required Future<ApplicationResult<R>> Function(String operationId) action,
|
required Future<ApplicationResult<ApplicationCommandResult>> Function(
|
||||||
|
String operationId,
|
||||||
|
)
|
||||||
|
action,
|
||||||
}) async {
|
}) async {
|
||||||
final operationId = _nextOperationId();
|
final operationId = _nextOperationId();
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
// 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,961 +0,0 @@
|
||||||
// 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
|
|
@ -1,657 +0,0 @@
|
||||||
// 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()}%';
|
|
||||||
}
|
|
||||||
|
|
@ -1,516 +0,0 @@
|
||||||
// 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
182
apps/focus_flow_flutter/lib/widgets/backlog_pane.dart
Normal file
182
apps/focus_flow_flutter/lib/widgets/backlog_pane.dart
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// 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'}',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
// 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,317 +0,0 @@
|
||||||
// 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')}';
|
|
||||||
}
|
|
||||||
|
|
@ -6,23 +6,12 @@ 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';
|
||||||
|
|
||||||
/// Sidebar for primary FocusFlow navigation.
|
/// Static sidebar for primary FocusFlow navigation.
|
||||||
class Sidebar extends StatelessWidget {
|
class Sidebar extends StatelessWidget {
|
||||||
/// Creates the FocusFlow sidebar.
|
/// Creates the FocusFlow sidebar.
|
||||||
const Sidebar({
|
const Sidebar({super.key});
|
||||||
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.
|
||||||
|
|
@ -40,44 +29,19 @@ class Sidebar extends StatelessWidget {
|
||||||
_NavItem(
|
_NavItem(
|
||||||
label: 'Today',
|
label: 'Today',
|
||||||
icon: Icons.calendar_today,
|
icon: Icons.calendar_today,
|
||||||
section: FocusFlowSection.today,
|
active: true,
|
||||||
active: activeSection == FocusFlowSection.today,
|
onTap: () {},
|
||||||
onTap: onSectionSelected,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_NavItem(
|
_NavItem(label: 'Backlog', icon: Icons.layers, onTap: () {}),
|
||||||
label: 'Backlog',
|
|
||||||
icon: Icons.layers,
|
|
||||||
section: FocusFlowSection.backlog,
|
|
||||||
active: activeSection == FocusFlowSection.backlog,
|
|
||||||
onTap: onSectionSelected,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_NavItem(
|
_NavItem(label: 'Projects', icon: Icons.work_outline, onTap: () {}),
|
||||||
label: 'Projects',
|
|
||||||
icon: Icons.work_outline,
|
|
||||||
section: FocusFlowSection.projects,
|
|
||||||
active: activeSection == FocusFlowSection.projects,
|
|
||||||
onTap: onSectionSelected,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_NavItem(
|
_NavItem(label: 'Reports', icon: Icons.bar_chart, onTap: () {}),
|
||||||
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(
|
_NavItem(label: 'Settings', icon: Icons.settings, onTap: () {}),
|
||||||
label: 'Settings',
|
|
||||||
icon: Icons.settings,
|
|
||||||
section: FocusFlowSection.settings,
|
|
||||||
active: activeSection == FocusFlowSection.settings,
|
|
||||||
onTap: onSectionSelected,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -133,7 +97,6 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
@ -146,13 +109,9 @@ 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 ValueChanged<FocusFlowSection> onTap;
|
final VoidCallback 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.
|
||||||
|
|
@ -168,11 +127,9 @@ class _NavItem extends StatelessWidget {
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
key: ValueKey(
|
key: active ? const ValueKey('nav-today-active') : null,
|
||||||
active ? 'nav-${section.name}-active' : 'nav-${section.name}',
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius),
|
borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius),
|
||||||
onTap: () => onTap(section),
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 54,
|
height: 54,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,9 @@ 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';
|
||||||
|
|
@ -33,7 +31,6 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
this.onPushToTomorrow,
|
this.onPushToTomorrow,
|
||||||
this.onPushToBacklog,
|
this.onPushToBacklog,
|
||||||
this.onRemove,
|
this.onRemove,
|
||||||
this.onBreakUp,
|
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -58,26 +55,6 @@ 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
|
||||||
|
|
@ -155,21 +132,8 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -19,21 +19,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -67,18 +52,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -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 * 4 + actionGap;
|
double get actionsWidth => actionButtonSize * 3 + 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.
|
||||||
|
|
|
||||||
|
|
@ -124,55 +124,3 @@ 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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ 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.
|
||||||
|
|
@ -54,9 +53,6 @@ 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
|
||||||
|
|
@ -94,20 +90,6 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,6 @@ 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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -114,25 +111,6 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ 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.
|
||||||
|
|
@ -49,9 +48,6 @@ 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
|
||||||
|
|
@ -70,7 +66,6 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,10 @@ 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'
|
import 'package:scheduler_core/scheduler_core.dart' show logger;
|
||||||
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';
|
||||||
|
|
@ -39,7 +37,6 @@ class TaskTimelineCard extends StatefulWidget {
|
||||||
this.onPushToNext,
|
this.onPushToNext,
|
||||||
this.onPushToTomorrow,
|
this.onPushToTomorrow,
|
||||||
this.onPushToBacklog,
|
this.onPushToBacklog,
|
||||||
this.onBreakUpCard,
|
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -61,13 +58,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ 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';
|
||||||
|
|
@ -31,7 +29,6 @@ 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,
|
||||||
|
|
@ -63,14 +60,6 @@ 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,6 @@ 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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ class TopBar extends StatelessWidget {
|
||||||
this.onNextDate,
|
this.onNextDate,
|
||||||
this.onDatePressed,
|
this.onDatePressed,
|
||||||
this.onDateLabelPressed,
|
this.onDateLabelPressed,
|
||||||
this.onOpenSettings,
|
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -41,9 +40,6 @@ 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
|
||||||
|
|
@ -134,7 +130,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, onPressed: onOpenSettings),
|
_SettingsButton(metrics: metrics),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,18 @@ 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, this.onPressed});
|
const _SettingsButton({required this.metrics});
|
||||||
|
|
||||||
/// 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(
|
||||||
key: const ValueKey('top-bar-settings-button'),
|
onPressed: () {},
|
||||||
onPressed: onPressed,
|
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: FocusFlowTokens.textPrimary,
|
foregroundColor: FocusFlowTokens.textPrimary,
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: drift
|
name: drift
|
||||||
sha256: c21b9af62e78f86837638dda6c33506bd9444ca8a32cad2b99c07369cf9e2462
|
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.34.1"
|
version: "2.34.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
// 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};
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
// 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 [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -552,7 +552,6 @@ 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 {},
|
||||||
|
|
|
||||||
|
|
@ -41,34 +41,6 @@ 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 {
|
||||||
|
|
@ -124,9 +96,9 @@ void main() {
|
||||||
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets('task modal opens closes and keeps compact actions inert', (
|
||||||
'task modal opens and closes, and Break up opens a cancellable dialog',
|
tester,
|
||||||
(tester) async {
|
) 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);
|
||||||
|
|
@ -135,10 +107,7 @@ void main() {
|
||||||
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(
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
find.byKey(const ValueKey('task-selection-modal')),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
expect(find.text('Pay bill'), findsNWidgets(2));
|
expect(find.text('Pay bill'), findsNWidgets(2));
|
||||||
expect(find.textContaining('Required'), findsWidgets);
|
expect(find.textContaining('Required'), findsWidgets);
|
||||||
expect(find.byTooltip('Reward level 2'), findsOneWidget);
|
expect(find.byTooltip('Reward level 2'), findsOneWidget);
|
||||||
|
|
@ -155,16 +124,7 @@ void main() {
|
||||||
await tester.tap(find.text('Break up'));
|
await tester.tap(find.text('Break up'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Break up task'), findsOneWidget);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('break-up-confirm')), findsOneWidget);
|
|
||||||
|
|
||||||
await tester.tap(find.text('Cancel'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
find.byKey(const ValueKey('task-selection-modal')),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
expect(find.text('Pay bill'), findsNWidgets(2));
|
expect(find.text('Pay bill'), findsNWidgets(2));
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('close-task-modal')));
|
await tester.tap(find.byKey(const ValueKey('close-task-modal')));
|
||||||
|
|
@ -180,8 +140,7 @@ void main() {
|
||||||
|
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
||||||
expect(find.text('Pay bill'), findsOneWidget);
|
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,
|
||||||
|
|
@ -911,51 +870,6 @@ 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 {
|
||||||
|
|
@ -1309,11 +1223,6 @@ 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),
|
||||||
|
|
@ -1333,7 +1242,6 @@ Future<void> _pumpTimelineCard(
|
||||||
onPushToNext: onPushToNext,
|
onPushToNext: onPushToNext,
|
||||||
onPushToTomorrow: onPushToTomorrow,
|
onPushToTomorrow: onPushToTomorrow,
|
||||||
onPushToBacklog: onPushToBacklog,
|
onPushToBacklog: onPushToBacklog,
|
||||||
onBreakUpCard: onBreakUpCard,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,999 +0,0 @@
|
||||||
// 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 [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,269 +0,0 @@
|
||||||
// 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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<!-- 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 **SQLite‑first** persistence.
|
|
||||||
We need a stable, versioned relational schema that maps cleanly to the domain
|
|
||||||
objects while staying behind the repository abstraction. Drift will manage
|
|
||||||
migrations.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
* **Database file**: `adhd_scheduler.sqlite` in the user data directory.
|
|
||||||
* **Schema version**: `1` (managed by Drift).
|
|
||||||
* **Tables**
|
|
||||||
|
|
||||||
| Table | Purpose | Key fields |
|
|
||||||
|-------|---------|------------|
|
|
||||||
| `tasks` | Authoritative task rows | `id TEXT PRIMARY KEY`, `owner_id TEXT`, `project_id`, `parent_id`, `type`, `status`, `priority`, `reward`, `difficulty`, `scheduled_start_utc`, `scheduled_end_utc`, `actual_start_utc`, `actual_end_utc`, `completed_at_utc`, `revision INT`, `created_at_utc`, `updated_at_utc`, `backlog_entered_at_utc`, `backlog_entered_provenance TEXT` |
|
|
||||||
| `projects` | Project configuration | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `color_key`, config defaults …, `archived_at_utc`, `revision` |
|
|
||||||
| `locked_blocks` | Recurring / one‑off locked time | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `date TEXT`, `start_time TEXT`, `end_time TEXT`, `recurrence_json`, `hidden_by_default INT`, `archived_at_utc`, `revision` |
|
|
||||||
| `locked_overrides` | Date‑scoped overrides | `id TEXT PRIMARY KEY`, `owner_id`, `locked_block_id`, `date TEXT`, `type`, JSON fields |
|
|
||||||
| `settings` | One row per owner | `owner_id TEXT PRIMARY KEY`, `timezone_id`, `day_start_minutes`, `day_end_minutes`, `compact_mode INT`, `revision` |
|
|
||||||
| `activities` | Append‑only internal events | `id TEXT PRIMARY KEY`, `owner_id`, `task_id`, `code`, `occurred_at_utc`, `metadata_json` |
|
|
||||||
| `snapshots` | Bounded diagnostic schedule snapshots | `id TEXT PRIMARY KEY`, `owner_id`, `operation_name`, `source_date TEXT`, `window_json`, `notice_json`, `changes_json`, `retention_expires_utc` |
|
|
||||||
|
|
||||||
* All timestamps are stored as **UTC**.
|
|
||||||
* Optimistic concurrency: every mutable row carries `revision`. Updates must
|
|
||||||
include `WHERE revision = :expected` and increment on success.
|
|
||||||
* Drift migrations are generated and tested; downgrades are not supported.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
* Domain objects remain unchanged; adapters translate.
|
|
||||||
* Backup library (Block 24) can copy and encrypt this single file.
|
|
||||||
* Repository contract tests assert schema integrity via Drift introspection.
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<!-- 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** – passphrase‑encrypted SQLite copy (`.sqlite.aes` via AES‑256‑GCM, PBKDF2 200 k rounds).
|
|
||||||
* **Readable export** – JSON and CSV via `ExportController`.
|
|
||||||
|
|
||||||
No open questions remain.
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<!-- 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.
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<!-- 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.
|
|
||||||
|
|
@ -17,10 +17,6 @@ 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,
|
||||||
|
|
@ -33,14 +29,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -229,10 +229,6 @@ 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 ||
|
||||||
|
|
@ -247,22 +243,11 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -285,88 +285,6 @@ 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({
|
||||||
|
|
@ -457,106 +375,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -18,34 +18,17 @@ 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';
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue