Compare commits
26 commits
main
...
feat/9-12-
| Author | SHA1 | Date | |
|---|---|---|---|
| b85742fcca | |||
| 92e5358c02 | |||
| 383130ebdb | |||
| 9b99c7810e | |||
| ce997dcf2e | |||
| bfba203d1e | |||
| 3b950358c2 | |||
| 6f0da3c8aa | |||
| 1c945fad3f | |||
| 8a1d2bfc9d | |||
| c730c33569 | |||
| 51ab9aa014 | |||
| dd25bdfaae | |||
| 2bcbba46b6 | |||
| 73ef8c2543 | |||
| 82f1fd43b7 | |||
| 60039be127 | |||
| d26645affb | |||
| f41a7ff034 | |||
| 39326ef3dd | |||
| b71d74290f | |||
| f8bd0e1dd6 | |||
| edfde0a2d0 | |||
| 932a7409fc | |||
| 88a66eff27 | |||
| c357b7d97d |
116 changed files with 16055 additions and 429 deletions
49
.forgejo/workflows/ci.yml
Normal file
49
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
# Forgejo-native CI for the Circuit-Forge focus-flow fork.
|
||||
#
|
||||
# Ashley's upstream .github/workflows/ci.yml targets GitHub-hosted
|
||||
# ubuntu-latest/windows-latest/macos-latest runners, which don't exist on
|
||||
# this self-hosted instance for non-org repos (that workflow shows every
|
||||
# matrix job as "Has been cancelled" on eva/focus-flow). This workflow
|
||||
# mirrors the working pattern already used by circuitforge-core, peregrine,
|
||||
# and kiwi on this instance: ubuntu-latest only.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'feat/**', 'fix/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
workspace:
|
||||
name: Pure-Dart workspace packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dart-lang/setup-dart@v1
|
||||
with:
|
||||
sdk: stable
|
||||
- run: dart pub get
|
||||
- run: dart analyze
|
||||
- run: dart run scripts/test.dart
|
||||
|
||||
flutter_app:
|
||||
name: Flutter app (analyze, test, format)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/focus_flow_flutter
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: 3.44.4
|
||||
channel: stable
|
||||
- run: flutter pub get
|
||||
- run: flutter analyze
|
||||
- run: flutter test
|
||||
- run: dart format --set-exit-if-changed lib test
|
||||
11
.gitleaks.toml
Normal file
11
.gitleaks.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Local Claude Code settings file - globally .gitignore'd, never enters git history; contains recorded bash command patterns (including auth headers) from prior approved tool calls, not repo secrets"
|
||||
paths = [
|
||||
'''\.claude/settings\.local\.json''',
|
||||
]
|
||||
82
AGENTS.md
82
AGENTS.md
|
|
@ -1,7 +1,7 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- 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.
|
||||
|
||||
|
|
@ -10,9 +10,10 @@ This document supersedes all previous agent rule files. Treat it as the single s
|
|||
## 0. Quick‑start for Codex
|
||||
|
||||
* Blocks live in `Codex Documentation/Current Software Plan/`.
|
||||
* Work strictly in numerical order (Block 19 → 20 … 29).
|
||||
* Each block contains numbered `XHIGH` / `HIGH` chunks.
|
||||
* Stop at every `BREAKPOINT` in a chunk; wait for confirmation before continuing.
|
||||
* Work strictly in numerical order unless the active plan explicitly says otherwise.
|
||||
* Treat each block or named plan as the primary work unit.
|
||||
* New or updated plan documents must use blocks of work, implementation steps, tasks, or acceptance checks.
|
||||
* Stop at any explicit `BREAKPOINT` in the active block or plan; wait for confirmation before continuing.
|
||||
* Before coding, run `scripts/bootstrap_dev.sh` to set up the local dev DB.
|
||||
|
||||
---
|
||||
|
|
@ -21,13 +22,13 @@ This document supersedes all previous agent rule files. Treat it as the single s
|
|||
|
||||
| Element | Rule |
|
||||
|---------|------|
|
||||
| Local storage | **SQLite via Drift**, schemaVersion 1 |
|
||||
| Abstraction | Domain‑only repository interfaces (`scheduler_persistence`) |
|
||||
| Local storage | **SQLite via Drift**, schemaVersion 1 |
|
||||
| Abstraction | Domain-only repository interfaces (`scheduler_persistence`) |
|
||||
| Swappable | New adapters must pass repository conformance tests |
|
||||
| Backup | AES‑256‑GCM encrypted SQLite file (`Backup library`, Block 24) |
|
||||
| Migrations | Drift migrations with tests (Block 20, Block 26) |
|
||||
| Backup | AES-256-GCM encrypted SQLite file (`Backup library`, Block 24) |
|
||||
| Migrations | Drift migrations with tests (Block 20, Block 26) |
|
||||
|
||||
No MongoDB runtime in V1.
|
||||
SQLite is the selected V1 implementation database. Keep Drift and database details inside the SQLite persistence adapter; core/domain code must not import database libraries or depend on row shapes.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -35,15 +36,15 @@ No MongoDB runtime in V1.
|
|||
|
||||
1. Interfaces expose domain objects only.
|
||||
2. Optimistic `revision` on every mutable save.
|
||||
3. Owner scope parameter now for future multi‑user.
|
||||
4. Adapters implement compare‑and‑set; core never overwrites stale revision.
|
||||
3. Owner scope parameter now for future multi-user.
|
||||
4. Adapters implement compare-and-set; core never overwrites stale revision.
|
||||
|
||||
---
|
||||
|
||||
## 3. Notification Rules
|
||||
|
||||
* Use `NotificationAdapter` (Block 21).
|
||||
* Desktop implementation (Block 22), fake adapter for tests.
|
||||
* Use `NotificationAdapter` (Block 21).
|
||||
* Desktop implementation (Block 22), fake adapter for tests.
|
||||
* Core never imports platform APIs directly.
|
||||
|
||||
---
|
||||
|
|
@ -51,7 +52,7 @@ No MongoDB runtime in V1.
|
|||
## 4. Backup / Export Rules
|
||||
|
||||
* Backup: encrypted SQLite (`.sqlite.aes`) via Backup library.
|
||||
* Readable exports: JSON + CSV via `ExportController` (Block 23).
|
||||
* Readable exports: JSON + CSV via `ExportController` (Block 23).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -64,16 +65,16 @@ No MongoDB runtime in V1.
|
|||
| Migration | `test/migration` | Drift schema upgrades |
|
||||
| Integration | `test/integration` | full stack InMemory + SQLite + fake notifications |
|
||||
|
||||
CI fails below **80 % line coverage**.
|
||||
CI fails below **80% line coverage**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dev Scripts (Block 27)
|
||||
## 6. Dev Scripts (Block 27)
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `bootstrap_dev.sh` | install deps, create dev DB |
|
||||
| `dev.sh` | hot‑reload desktop run |
|
||||
| `dev.sh` | hot-reload desktop run |
|
||||
| `test.sh` | all tests + coverage |
|
||||
| `package_release.sh` | build OS binaries |
|
||||
|
||||
|
|
@ -83,29 +84,48 @@ integrated.
|
|||
|
||||
---
|
||||
|
||||
## 6.1 File Hygiene
|
||||
## 6.1 Code Documentation & File Hygiene
|
||||
|
||||
All future project files must include the relevant SPDX metadata for their file
|
||||
format. New Dart files must include file-level Dartdoc library docs and Dartdoc
|
||||
comments for every class, enum, enum value, constructor, method, field, and
|
||||
top-level declaration. When adding code, keep large feature surfaces organized
|
||||
under descriptive subfolders instead of expanding flat top-level `src` or app
|
||||
directories.
|
||||
format.
|
||||
|
||||
All new and modified code must be commented or documented in detail while the
|
||||
work is being done. Update existing comments and docs whenever functionality,
|
||||
contracts, invariants, side effects, persistence mappings, scheduling rules, or
|
||||
error behavior change. Stale comments are treated as incorrect code.
|
||||
|
||||
New Dart files must include file-level Dartdoc library docs and Dartdoc comments
|
||||
for every class, enum, enum value, constructor, method, field, and top-level
|
||||
declaration. Existing Dart declarations touched by a change must have their
|
||||
Dartdoc added or updated as part of that same change.
|
||||
|
||||
Keep files small and focused. If a file grows to cover multiple responsibilities,
|
||||
split it into smaller files with clear names rather than expanding a broad
|
||||
catch-all file. Use increasingly specific folder scopes, ordered from broad
|
||||
package/feature ownership down to concrete responsibility. Prefer descriptive
|
||||
folder names that explain the domain area or adapter boundary.
|
||||
|
||||
Aim for fewer than 10 files per folder. This is a guideline, not a hard rule:
|
||||
do not create artificial one-file folders merely to satisfy the count, and do
|
||||
split crowded folders when the files naturally form smaller responsibility
|
||||
groups.
|
||||
|
||||
---
|
||||
|
||||
## 7. Branch, Commit & CI
|
||||
|
||||
* Start each new block from `main` on a block branch named
|
||||
`block-XX-simple-name` (for example, `block-20-sqlite-adapter`).
|
||||
* Keep all chunk work for that block on the block branch.
|
||||
* Commit every completed chunk before moving to the next chunk or breakpoint.
|
||||
* Start each new block or plan from `main` on a dedicated branch named for the
|
||||
work, such as `block-20-sqlite-adapter` or `plan-backlog-tab`.
|
||||
* Keep all work for the active block or plan on its dedicated branch.
|
||||
* Commit completed work after it is implemented, documented, and locally
|
||||
validated.
|
||||
* Do not leave completed work only in the working tree.
|
||||
* Use conventional commits (`feat`, `fix`, `docs`, `test`, `refactor`,
|
||||
`chore`, `ci`).
|
||||
* Do not leave completed chunk work only in the working tree.
|
||||
* When the block is complete and verified, merge the block branch back into
|
||||
* When a full plan is complete and no work remains for that plan, rerun the
|
||||
required validation. If tests still pass, merge the plan branch back into
|
||||
`main`.
|
||||
* CI matrix: ubuntu‑latest, windows‑latest, macos‑latest.
|
||||
* CI matrix: ubuntu-latest, windows-latest, macos-latest.
|
||||
* `dart analyze` and `dart test` must pass.
|
||||
|
||||
---
|
||||
|
|
@ -125,4 +145,4 @@ directories.
|
|||
|
||||
---
|
||||
|
||||
_Last updated: 2026-06-27_
|
||||
_Last updated: 2026-07-07_
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ Included documents cover:
|
|||
ADRs, public API baseline, and requirements traceability matrix.
|
||||
- UI Plan 1, the implemented compact Today timeline mockup plan, including its
|
||||
source mockups and completed block handoff notes.
|
||||
- UI Plan 2, the implemented Backlog board plan, including public scheduler
|
||||
read models, command-backed drawer actions, board summary, and validation
|
||||
handoff notes.
|
||||
- Persistence Plan 1, the implemented SQLite runtime persistence slice for
|
||||
normal Flutter startup and close/reopen task lifecycle proof.
|
||||
- Date Selection 01, the completed day navigation and date picker plan for the
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 Block 01 — Repo Scope, Guardrails, and Mockup Assets
|
||||
|
||||
**Goal:** Prepare the repo for Backlog board implementation without changing product behavior yet.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, Codex has verified repo conventions, copied/registered the mockups for local reference, captured baseline validation, and identified the smallest safe implementation path.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1.1 — Read repo instructions and baseline state
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Read `AGENTS.md` first.
|
||||
2. Read root `README.md`.
|
||||
3. Read `apps/focus_flow_flutter/README.md`.
|
||||
4. Read UI Plan 1 summary and block files under:
|
||||
- `Codex Documentation/Completed Plans/UI Plan 1 - Compact Today Timeline Mockup/`
|
||||
5. Inspect current Backlog-related files:
|
||||
- `apps/focus_flow_flutter/lib/widgets/backlog_pane.dart`
|
||||
- `apps/focus_flow_flutter/lib/widgets/schedule_components.dart`
|
||||
- `apps/focus_flow_flutter/lib/controllers/scheduler_read_controller.dart`
|
||||
- `apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart`
|
||||
- `packages/scheduler_core/lib/src/application/application_management/**`
|
||||
- `packages/scheduler_core/lib/src/scheduling/backlog/**`
|
||||
6. Confirm that Flutter app code imports only public scheduler APIs.
|
||||
7. Confirm current tests and expected command names.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Codex understands that V1 is SQLite-first and Flutter must not import persistence adapters directly.
|
||||
- Codex has identified whether domain/persistence changes are needed for notes/tags/remove/someday.
|
||||
- No code behavior changed in this chunk.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1.2 — Place mockup references in the plan folder
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Ensure the plan folder contains:
|
||||
- `mockups/backlog_board_default.png`
|
||||
- `mockups/backlog_task_drawer.png`
|
||||
2. Add or preserve `mockups/README.md` explaining which image represents which state.
|
||||
3. Do not store generated image prompt text in code comments.
|
||||
4. Do not overwrite existing UI Plan 1 mockups.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Mockup files are available from the plan folder.
|
||||
- The default and drawer states are unambiguous.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1.3 — Run baseline validation
|
||||
|
||||
### Tasks
|
||||
|
||||
From repo root:
|
||||
|
||||
```sh
|
||||
dart analyze
|
||||
dart test
|
||||
```
|
||||
|
||||
From Flutter app:
|
||||
|
||||
```sh
|
||||
cd apps/focus_flow_flutter
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
If environment setup is missing, run the highest-confidence subset and record exact failures.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Baseline validation result is known before coding.
|
||||
- Existing failures are documented separately from new work.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1.4 — Create implementation notes for scope boundaries
|
||||
|
||||
### Tasks
|
||||
|
||||
Create a short working note, either in the block commit message or a temporary implementation checklist, identifying:
|
||||
|
||||
1. Which files currently render the sidebar and app shell.
|
||||
2. Which controller/composition object will own navigation state.
|
||||
3. Which read-model expansion is required for the board.
|
||||
4. Which commands already exist and which must be added.
|
||||
5. Which UI widgets can be reused from UI Plan 1:
|
||||
- dark theme tokens,
|
||||
- reward icon,
|
||||
- difficulty bars,
|
||||
- selected overlay patterns,
|
||||
- top-bar button style patterns.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Codex has a narrow implementation map before writing new code.
|
||||
- No speculative large refactors are planned.
|
||||
|
||||
BREAKPOINT: Stop here if baseline tests fail in a way that could hide regressions.
|
||||
|
||||
---
|
||||
|
||||
## Block acceptance criteria
|
||||
|
||||
- Repo instructions read.
|
||||
- Mockup assets placed and documented.
|
||||
- Baseline validation attempted and recorded.
|
||||
- No unrelated code changed.
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 Block 02 — Backlog Read Models, Metadata, and Demo Data
|
||||
|
||||
**Goal:** Add the application/read-model support needed to render the Backlog board, summary, and drawer from backend-owned data.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, the scheduler/application layer can provide a Backlog board projection with columns, summary statistics, detail metadata, and suggested slot previews. Flutter should not need to invent board grouping or summary counts.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.1 — Audit current backlog API and identify exact extension points
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Inspect the public export path in `packages/scheduler_core/lib/scheduler_core.dart`.
|
||||
2. Inspect existing:
|
||||
- `GetBacklogRequest`
|
||||
- `BacklogQueryResult`
|
||||
- `BacklogItemReadModel`
|
||||
- `BacklogView`
|
||||
- `BacklogFilter`
|
||||
- `BacklogSortKey`
|
||||
- `V1ApplicationManagementUseCases.getBacklog`
|
||||
3. Decide whether to extend existing types or add board-specific types.
|
||||
4. Prefer adding board-specific read models if extending existing types would break existing tests or consumers.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Existing simple backlog query remains compatible.
|
||||
- New board query is public through `scheduler_core.dart` if Flutter needs it.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.2 — Add board bucket policy in pure Dart
|
||||
|
||||
### Tasks
|
||||
|
||||
Add a deterministic policy type in scheduler core, such as:
|
||||
|
||||
- `BacklogBoardBucket`
|
||||
- `BacklogBoardBucketPolicy`
|
||||
- `BacklogBoardBucketResolution`
|
||||
|
||||
Default V1 policy, unless repo docs already define a better one:
|
||||
|
||||
1. `notNow`
|
||||
- task has `BacklogTag.wishlist`, or
|
||||
- priority is `veryLow`/`low` and not otherwise urgent, or
|
||||
- duration is missing and task is not ready to schedule.
|
||||
2. `breakUpFirst`
|
||||
- duration is `>= 90`, or
|
||||
- difficulty is `hard`/`veryHard`, or
|
||||
- task has child previews / parent-child metadata indicating it is already broken down.
|
||||
3. `needTimeBlock`
|
||||
- duration is `>= 45`, or
|
||||
- priority is `high`/`veryHigh`, or
|
||||
- task is estimated to require focused work.
|
||||
4. `doNext`
|
||||
- default for schedule-ready flexible backlog items.
|
||||
|
||||
Each resolution must include a short user-facing reason string.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Policy is pure Dart.
|
||||
- Policy is deterministic.
|
||||
- Unit tests cover all four buckets and tie-breaking precedence.
|
||||
- No Flutter code duplicates this policy.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.3 — Add board and summary read models
|
||||
|
||||
### Tasks
|
||||
|
||||
Add read models following repo style. Exact file paths may vary, but keep them under scheduler core application management read models.
|
||||
|
||||
Required models:
|
||||
|
||||
1. `BacklogBoardQueryResult`
|
||||
2. `BacklogBoardColumnReadModel`
|
||||
3. `BacklogBoardItemReadModel`
|
||||
4. `BacklogBoardSummaryReadModel`
|
||||
5. `BacklogDistributionReadModel`
|
||||
6. `BacklogDistributionEntryReadModel`
|
||||
7. `BacklogChildPreviewReadModel`
|
||||
8. `BacklogTaskDetailReadModel`
|
||||
9. `SuggestedSlotReadModel`
|
||||
10. `ProjectOptionReadModel` if not already available.
|
||||
|
||||
Required summary fields:
|
||||
|
||||
- total task count,
|
||||
- total estimated minutes,
|
||||
- count by priority,
|
||||
- count by project,
|
||||
- count and percentage by duration bucket,
|
||||
- planning tip copy.
|
||||
|
||||
Required card fields:
|
||||
|
||||
- task id,
|
||||
- title,
|
||||
- subtitle/notes preview,
|
||||
- project id/name/color token,
|
||||
- duration,
|
||||
- priority,
|
||||
- reward,
|
||||
- difficulty,
|
||||
- bucket,
|
||||
- bucket reason,
|
||||
- created/updated timestamps,
|
||||
- child previews,
|
||||
- tag strings.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Read models are immutable where repo conventions expect immutability.
|
||||
- Read models expose display-ready values only where appropriate; do not bury domain objects inside the UI-only result if that would leak unnecessary mutation surface.
|
||||
- Existing `BacklogQueryResult` tests remain passing.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.4 — Add notes and freeform tags metadata if absent
|
||||
|
||||
### Tasks
|
||||
|
||||
Current `Task` appears to lack freeform notes and general tags. Add the minimal V1 metadata needed by the drawer:
|
||||
|
||||
- `String? notes`
|
||||
- `Set<String> tags` or a small `TaskTag` value object if repo style requires validation.
|
||||
|
||||
Validation rules:
|
||||
|
||||
- notes may be null or non-empty trimmed string; blank notes normalize to null if the repo has normalization helpers.
|
||||
- tag labels are trimmed.
|
||||
- blank tags are rejected or filtered consistently.
|
||||
- duplicate tags collapse case-insensitively if repo conventions support that; otherwise preserve normalized unique strings.
|
||||
- impose a safe maximum count/length consistent with existing persistence payload guards.
|
||||
|
||||
Update all affected layers:
|
||||
|
||||
- `Task` constructor and `copyWith`.
|
||||
- quick capture/create paths where tags/notes are supported.
|
||||
- document mapping.
|
||||
- persistence contract fields.
|
||||
- SQLite task table and DAO/repository mapping.
|
||||
- in-memory repository if it stores full domain objects.
|
||||
- tests.
|
||||
|
||||
If adding persistence migration is required, keep it within SQLite/Drift V1 conventions and add migration tests if existing schema tooling supports them.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Existing tasks still construct without notes/tags.
|
||||
- Notes/tags round-trip through repository mappings.
|
||||
- Flutter receives notes/tags from public read models.
|
||||
- No direct SQLite/Drift import leaks into Flutter.
|
||||
|
||||
BREAKPOINT: Stop here if notes/tags would require a broad migration beyond the current plan. Ask Ashley whether drawer tags should be read-only placeholders or true V1 metadata.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.5 — Add board query use case
|
||||
|
||||
### Tasks
|
||||
|
||||
Add a method to `V1ApplicationManagementUseCases`, such as:
|
||||
|
||||
```dart
|
||||
Future<ApplicationResult<BacklogBoardQueryResult>> getBacklogBoard(
|
||||
GetBacklogBoardRequest request,
|
||||
)
|
||||
```
|
||||
|
||||
Request should include:
|
||||
|
||||
- `ApplicationOperationContext context`,
|
||||
- selected `CivilDate localDate` for slot suggestions,
|
||||
- optional search text,
|
||||
- optional filters,
|
||||
- sort key,
|
||||
- group mode.
|
||||
|
||||
Behavior:
|
||||
|
||||
1. Load backlog tasks through repositories.
|
||||
2. Load projects for display names/colors.
|
||||
3. Load child relationships/previews as needed.
|
||||
4. Apply search/filter/sort/group using application/core policies.
|
||||
5. Resolve board bucket for every item.
|
||||
6. Build summary from the same filtered item set.
|
||||
7. Return stable column order: Do Next, Need Time Block, Break Up First, Not Now.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Query does not mutate repositories.
|
||||
- Query returns all four columns even when empty.
|
||||
- Counts match filtered item set.
|
||||
- Summary and board columns use the same source task list.
|
||||
- Unit tests cover empty backlog, default seed-like backlog, search/filter, and summaries.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.6 — Add task detail query and suggested slot preview
|
||||
|
||||
### Tasks
|
||||
|
||||
Add a detail query method, such as:
|
||||
|
||||
```dart
|
||||
Future<ApplicationResult<BacklogTaskDetailReadModel>> getBacklogTaskDetail(
|
||||
GetBacklogTaskDetailRequest request,
|
||||
)
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
1. Load selected backlog task.
|
||||
2. Load project options.
|
||||
3. Load notes/tags/duration/reward/difficulty/project.
|
||||
4. Generate suggested slots without mutating state.
|
||||
5. Suggested slots should respect locked/required time by using scheduler/application services or cloned scheduler input.
|
||||
6. If exact slot-specific scheduling is not supported, return preview rows and mark which one corresponds to `next available`.
|
||||
|
||||
Suggested slot labels for demo should support:
|
||||
|
||||
- `Great Fit`
|
||||
- `Okay`
|
||||
- no-fit or conflict copy if applicable.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Detail query returns not found if selected task no longer exists.
|
||||
- Suggested slots do not write snapshots/tasks.
|
||||
- Missing duration returns an empty suggestions list plus a display reason instead of crashing.
|
||||
- Unit tests prove the query is read-only.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.7 — Expand demo seed data for Backlog board
|
||||
|
||||
### Tasks
|
||||
|
||||
Create or extend app demo seed files under `apps/focus_flow_flutter/lib/app/demo/`.
|
||||
|
||||
Seed must include enough data to match the mockup:
|
||||
|
||||
- 24 backlog tasks total.
|
||||
- Column counts:
|
||||
- Do Next: 5
|
||||
- Need Time Block: 6
|
||||
- Break Up First: 6
|
||||
- Not Now: 5
|
||||
- Projects:
|
||||
- Home: 5
|
||||
- Work: 8
|
||||
- Personal: 5
|
||||
- Learning: 4
|
||||
- Side Project: 2
|
||||
- Priorities:
|
||||
- High: 7
|
||||
- Medium: 9
|
||||
- Low: 8
|
||||
- Total estimated time: 12h 15m.
|
||||
- Include visible mockup tasks:
|
||||
- Reply to 3 emails
|
||||
- Update project status
|
||||
- Schedule dentist appt
|
||||
- Clean up downloads folder
|
||||
- Plan tomorrow
|
||||
- Review Q3 budget
|
||||
- Plan next week
|
||||
- Define weekly goals
|
||||
- Time block schedule
|
||||
- Organize kitchen drawers
|
||||
- Build analytics dashboard
|
||||
- Write marketing strategy
|
||||
- Complete online course
|
||||
- Module 1: Foundations
|
||||
- Module 2: Deep Dive
|
||||
- Module 3: Practice
|
||||
- Learn guitar
|
||||
- Start YouTube channel
|
||||
- Travel to Japan
|
||||
- Home office upgrade
|
||||
- Read 2 books
|
||||
- `Review Q3 budget` must have:
|
||||
- duration 60 min,
|
||||
- Work project,
|
||||
- High priority,
|
||||
- reward 3/5 equivalent,
|
||||
- difficulty 2/5 equivalent,
|
||||
- notes shown in drawer,
|
||||
- tags `finance` and `review`,
|
||||
- suggested slots matching the drawer mockup labels.
|
||||
|
||||
The remaining 3 tasks may be seed-only hidden lower in scroll or included below the fold.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Seed data is domain/application data, not a hardcoded widget list.
|
||||
- Default board can render from public read models.
|
||||
- Summary totals match the mockup.
|
||||
|
||||
---
|
||||
|
||||
## Block acceptance criteria
|
||||
|
||||
- Public application/read models can produce the Backlog board, summary, and detail drawer data.
|
||||
- Notes/tags support is either implemented end-to-end or explicitly deferred at the breakpoint with 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
|
||||
```
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 Block 03 — Navigation and Backlog Screen Shell
|
||||
|
||||
**Goal:** Add the Backlog tab route/screen shell and make the sidebar navigation render `Backlog` as active.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, the app can switch between the existing Today screen and the new Backlog screen shell. The Backlog shell matches the finalized header/control layout but may still use placeholder board content until later blocks fill it in.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.1 — Introduce app section navigation state
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Add an app-level enum/model such as `FocusFlowSection` with values:
|
||||
- `today`
|
||||
- `backlog`
|
||||
- `projects`
|
||||
- `reports`
|
||||
- `settings`
|
||||
2. Move the currently hardcoded active `Today` sidebar state into `FocusFlowHome` or a small shell controller.
|
||||
3. Update `Sidebar` to accept:
|
||||
- `activeSection`,
|
||||
- `onSectionSelected` callback.
|
||||
4. Keep Projects/Reports/Settings visible. They may switch to placeholder empty screens or remain inert, but Today and Backlog must work.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Today still renders as before when active.
|
||||
- Backlog nav item can become active.
|
||||
- Sidebar widget tests can verify active styling for Today and Backlog.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.2 — Add Backlog screen controller skeleton
|
||||
|
||||
### Tasks
|
||||
|
||||
Add controller/read-state classes under app conventions, for example:
|
||||
|
||||
```text
|
||||
apps/focus_flow_flutter/lib/controllers/backlog_board_controller.dart
|
||||
apps/focus_flow_flutter/lib/models/backlog/backlog_board_screen_data.dart
|
||||
```
|
||||
|
||||
Controller responsibilities:
|
||||
|
||||
- load `BacklogBoardQueryResult`,
|
||||
- track loading/ready/empty/failure states,
|
||||
- track selected task id for drawer,
|
||||
- track search/filter/sort/group UI values,
|
||||
- request detail data for selected task,
|
||||
- expose reload method after commands.
|
||||
|
||||
Avoid canonical scheduling state in the controller.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Controller can load the demo/app composition read model.
|
||||
- Controller has no direct persistence dependency.
|
||||
- Selection is UI-local and not persisted.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.3 — Extend composition factory
|
||||
|
||||
### Tasks
|
||||
|
||||
Update `DemoSchedulerComposition` or the current app composition to create:
|
||||
|
||||
- Backlog board controller,
|
||||
- Backlog command controller if needed,
|
||||
- any date/context provider needed for suggestions.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Use same owner/timezone/date context style as Today.
|
||||
- Reuse existing in-memory application store seeded by domain objects.
|
||||
- Ensure Today and Backlog share the same underlying demo store so commands can refresh both later.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Backlog can load without constructing a separate unrelated fake app state.
|
||||
- Existing Today seed still works.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.4 — Build Backlog screen shell
|
||||
|
||||
### Tasks
|
||||
|
||||
Create `BacklogBoardScreen` or equivalent under a feature folder such as:
|
||||
|
||||
```text
|
||||
apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_screen.dart
|
||||
```
|
||||
|
||||
Screen sections:
|
||||
|
||||
1. Header title/subtitle row.
|
||||
2. Header mode/settings row.
|
||||
3. Search/filter/sort/group/new-task control row.
|
||||
4. Content body with board region and summary panel slot.
|
||||
5. Drawer overlay slot above content.
|
||||
|
||||
Use mockup spacing:
|
||||
|
||||
- main left/top padding close to current app gutter,
|
||||
- title around 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
|
||||
```
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 Block 04 — Board Columns and Task Cards
|
||||
|
||||
**Goal:** Render the four finalized Backlog board columns and task cards from Backlog board read models.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, the default Backlog board displays all four columns, counts, visible tasks, card metadata, child previews, and add-task footers.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4.1 — Add board visual tokens
|
||||
|
||||
### Tasks
|
||||
|
||||
Add or extend Flutter visual tokens for Backlog-specific accents:
|
||||
|
||||
- Do Next green.
|
||||
- Need Time Block orange/yellow.
|
||||
- Break Up First purple.
|
||||
- Not Now blue.
|
||||
- High priority red flag.
|
||||
- Medium priority yellow/orange flag.
|
||||
- Low priority green flag.
|
||||
- Project dot colors matching seed projects.
|
||||
- Tag chip colors for drawer later.
|
||||
|
||||
Prefer central token/mapping files over inline colors.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Colors are centralized.
|
||||
- Existing Today color mappings remain unchanged.
|
||||
- No one-off hardcoded color scatter in card widgets.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4.2 — Build column header widget
|
||||
|
||||
### Tasks
|
||||
|
||||
Create a reusable `BacklogBoardColumn` and `BacklogColumnHeader`.
|
||||
|
||||
Header content:
|
||||
|
||||
- bucket icon,
|
||||
- bucket title,
|
||||
- count pill,
|
||||
- add icon button on the far right,
|
||||
- subtitle line.
|
||||
|
||||
Icon suggestions:
|
||||
|
||||
- Do Next: check circle.
|
||||
- Need Time Block: clock.
|
||||
- Break Up First: target/radio button/segmented circle.
|
||||
- Not Now: cloud outline.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Header count is read from `BacklogBoardColumnReadModel.count`.
|
||||
- Header add icon calls the same create-task flow as the footer with bucket preselection if available.
|
||||
- Header does not compute bucket counts in Flutter.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4.3 — Build board layout
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Render columns in stable order:
|
||||
- Do Next
|
||||
- Need Time Block
|
||||
- Break Up First
|
||||
- Not Now
|
||||
2. Use horizontal layout at desktop width.
|
||||
3. Make board horizontally scrollable if width becomes constrained.
|
||||
4. Make column card stacks vertically scrollable or allow the whole board body to scroll, whichever best matches current app ergonomics.
|
||||
5. Keep the summary panel fixed on the right in default state.
|
||||
|
||||
Sizing guidance at 1672 px mockup width:
|
||||
|
||||
- board starts after left content gutter,
|
||||
- 4 columns fit before summary panel,
|
||||
- column width around 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
|
||||
```
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 Block 05 — Search, Filter, Sort, Group, and Mode Controls
|
||||
|
||||
**Goal:** Implement the Backlog top controls shown in the finalized default mockup with safe, deterministic behavior.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, the Backlog screen has working search/filter/sort/group controls that reload or transform the Backlog board without duplicating scheduling rules.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.1 — Search field
|
||||
|
||||
### Tasks
|
||||
|
||||
Add search field matching the mockup:
|
||||
|
||||
- leading search icon,
|
||||
- placeholder `Search backlog...`,
|
||||
- dark outlined field,
|
||||
- clear affordance when text is non-empty if it fits cleanly.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Debounce user input lightly or trigger search on change if current data size is small.
|
||||
- Search across title, subtitle/notes preview, project name, and tags.
|
||||
- Prefer passing `searchText` into the board query so search is part of the read-model request.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Typing filters cards and updates summary counts.
|
||||
- Clearing search restores all tasks.
|
||||
- Search does not alter persisted task data.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.2 — Filter menu
|
||||
|
||||
### Tasks
|
||||
|
||||
Implement `Filters` button and popover/menu.
|
||||
|
||||
Minimum filters:
|
||||
|
||||
- priority: High, Medium, Low,
|
||||
- project,
|
||||
- duration bucket,
|
||||
- bucket/column,
|
||||
- missing duration,
|
||||
- no reward set if existing backlog filter supports it.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Multi-select where useful.
|
||||
- Apply and clear controls.
|
||||
- Active filter count may be shown as a small badge if it does not clutter the mockup.
|
||||
- Filter state should update board columns and summary from the same result set.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Filter button opens a dark popover.
|
||||
- Applying filters updates columns and summary.
|
||||
- Clearing filters restores default board.
|
||||
- Filter logic lives in application read policy or safe query layer, not widget-only scheduling logic.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.3 — Sort dropdown
|
||||
|
||||
### Tasks
|
||||
|
||||
Implement `Sort: Custom` dropdown.
|
||||
|
||||
Sort options:
|
||||
|
||||
- Custom/default.
|
||||
- Priority.
|
||||
- Reward vs Effort.
|
||||
- Age.
|
||||
- Project.
|
||||
- Times Pushed if available.
|
||||
- Duration if added to board sorting.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Sort applies inside each bucket by default.
|
||||
- Do not reorder bucket columns.
|
||||
- Stable tie-breaker preserves source/read-model order.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Sort selection updates card order.
|
||||
- `Custom` default matches mockup seed ordering.
|
||||
- Sort state is visible in the control label.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.4 — Group dropdown
|
||||
|
||||
### Tasks
|
||||
|
||||
Implement `Group: None` dropdown.
|
||||
|
||||
Group options:
|
||||
|
||||
- None.
|
||||
- Project.
|
||||
- Priority.
|
||||
- Duration.
|
||||
|
||||
Behavior:
|
||||
|
||||
- `None` is default and matches mockup.
|
||||
- Non-none group mode adds compact section dividers inside each column or transforms card ordering only if section dividers are visually too disruptive.
|
||||
- Summary panel remains based on filtered item set, not group sections.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Group dropdown opens and changes state.
|
||||
- Group none returns the exact default visual organization.
|
||||
- No overflow or large layout jump when grouping.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.5 — Compact/Board toggle and Settings button
|
||||
|
||||
### Tasks
|
||||
|
||||
Implement the upper-right controls:
|
||||
|
||||
- `Compact` segment.
|
||||
- `Board` segment active.
|
||||
- `Settings` button.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Board is active by default.
|
||||
- If an existing compact backlog list can be safely reused, clicking Compact may swap to that list. Otherwise, keep Compact inert for this plan and document it in the widget callback TODO.
|
||||
- Settings remains visible and inert unless there is an existing settings screen.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Toggle visually matches mockup.
|
||||
- Board remains active by default.
|
||||
- Pressing Compact/Settings does not throw.
|
||||
- Tests assert Board selected state.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.6 — New Task button shell
|
||||
|
||||
### Tasks
|
||||
|
||||
Add magenta `+ New Task` button with trailing chevron.
|
||||
|
||||
Behavior for this block:
|
||||
|
||||
- Button calls a callback or opens a placeholder drawer shell.
|
||||
- Full create task wiring happens in Block 8.
|
||||
- Dropdown chevron may later expose quick destination choices; do not implement a broad menu unless already designed.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Button matches the mockup visually.
|
||||
- Button has a stable key for tests.
|
||||
- Button press is safe before full Block 8 wiring.
|
||||
|
||||
BREAKPOINT: Stop here if control behavior requires unresolved product decisions about compact mode or filter semantics.
|
||||
|
||||
---
|
||||
|
||||
## Block acceptance criteria
|
||||
|
||||
- Search/filter/sort/group controls are present and safe.
|
||||
- Default state matches mockup.
|
||||
- Interactions update or preserve board data predictably.
|
||||
- No scheduling rules are duplicated in Flutter.
|
||||
|
||||
## Validation after block
|
||||
|
||||
```sh
|
||||
cd apps/focus_flow_flutter
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 Block 06 — Backlog Summary Panel
|
||||
|
||||
**Goal:** Implement the right-side default Backlog summary panel from the finalized mockup.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, the default Backlog state includes a data-driven summary panel with totals, distributions, duration donut, and planning tip.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6.1 — Build summary panel structure
|
||||
|
||||
### Tasks
|
||||
|
||||
Create `BacklogSummaryPanel` under the backlog feature widgets.
|
||||
|
||||
Panel sections:
|
||||
|
||||
1. Header:
|
||||
- sparkle icon,
|
||||
- `Backlog summary`,
|
||||
- collapse chevron.
|
||||
2. Totals:
|
||||
- total tasks,
|
||||
- total estimated time.
|
||||
3. Priority distribution.
|
||||
4. Project distribution.
|
||||
5. Duration distribution.
|
||||
6. Planning tip card.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Panel consumes `BacklogBoardSummaryReadModel`.
|
||||
- No summary value is hardcoded in the widget.
|
||||
- Panel aligns to the right of the default board layout.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6.2 — Totals and distribution rows
|
||||
|
||||
### Tasks
|
||||
|
||||
Implement reusable compact distribution rows:
|
||||
|
||||
- icon/dot/flag at left,
|
||||
- label,
|
||||
- count at right.
|
||||
|
||||
Priority rows:
|
||||
|
||||
- High: red flag.
|
||||
- Medium: yellow flag.
|
||||
- Low: green flag.
|
||||
|
||||
Project rows:
|
||||
|
||||
- colored dot.
|
||||
- project name.
|
||||
- count.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Distribution rows match mockup density.
|
||||
- Counts come from summary read model.
|
||||
- Project colors use centralized visual token mapping.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6.3 — Duration donut chart
|
||||
|
||||
### Tasks
|
||||
|
||||
Implement a small donut chart using Flutter painting, not external chart dependencies unless already present.
|
||||
|
||||
Segments:
|
||||
|
||||
- `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
|
||||
```
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 Block 07 — Right-Anchored Task Detail Drawer
|
||||
|
||||
**Goal:** Implement the selected-task drawer shown in the finalized drawer mockup.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, selecting a backlog card opens a right-side drawer that overlays the board without shifting any existing elements.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 7.1 — Add selection and detail loading behavior
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Store selected task id in `BacklogBoardController`.
|
||||
2. On card click:
|
||||
- set selected task id,
|
||||
- request `BacklogTaskDetailReadModel`,
|
||||
- keep current board data unchanged.
|
||||
3. If another card is selected while drawer is open, replace detail content without closing first.
|
||||
4. If selected task disappears after a command/reload, close drawer and show quiet status if appropriate.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Card click opens drawer.
|
||||
- Selecting another card changes drawer content.
|
||||
- Selection is UI-local.
|
||||
- Detail query failures are rendered as drawer-local error, not full screen crash.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 7.2 — Implement drawer overlay anchoring
|
||||
|
||||
### Tasks
|
||||
|
||||
Use a `Stack` at the Backlog screen body level.
|
||||
|
||||
Drawer layout:
|
||||
|
||||
```dart
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: drawerWidth,
|
||||
child: BacklogTaskDrawer(...),
|
||||
)
|
||||
```
|
||||
|
||||
Sizing:
|
||||
|
||||
- Preferred width: 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
|
||||
```
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 Block 08 — Commands, New Task, Create/Edit Metadata, and Drawer Actions
|
||||
|
||||
**Goal:** Wire the Backlog 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
|
||||
```
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 Block 09 — Testing, Validation, Polish, and Handoff
|
||||
|
||||
**Goal:** Complete tests, final visual polish, validation commands, and handoff notes for the Backlog tab.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, the Backlog board implementation is tested, validated, documented, and ready for Ashley review or the next implementation plan.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 9.1 — Core/application tests
|
||||
|
||||
### Tests
|
||||
|
||||
Add or update tests for:
|
||||
|
||||
1. Backlog bucket policy:
|
||||
- Do Next,
|
||||
- Need Time Block,
|
||||
- Break Up First,
|
||||
- Not Now,
|
||||
- precedence/tie-breaking.
|
||||
2. Backlog board query:
|
||||
- empty state,
|
||||
- 24-task seed-like board,
|
||||
- search,
|
||||
- filters,
|
||||
- sort,
|
||||
- group none/default,
|
||||
- summary totals.
|
||||
3. Backlog detail query:
|
||||
- selected task detail,
|
||||
- notes/tags/project metadata,
|
||||
- suggested slots,
|
||||
- missing duration/no-fit.
|
||||
4. Commands:
|
||||
- create backlog task,
|
||||
- update metadata,
|
||||
- schedule backlog item,
|
||||
- push to someday,
|
||||
- remove/archive,
|
||||
- break up if implemented.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests are deterministic.
|
||||
- Tests do not require Flutter.
|
||||
- Tests do not depend on wall-clock time without injected context.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 9.2 — Flutter widget tests
|
||||
|
||||
### Tests
|
||||
|
||||
Add tests under `apps/focus_flow_flutter/test/` that verify:
|
||||
|
||||
1. Sidebar renders `Backlog` active on Backlog screen.
|
||||
2. Default Backlog screen shows:
|
||||
- `Backlog`,
|
||||
- `24 tasks`,
|
||||
- search placeholder,
|
||||
- `Filters`,
|
||||
- `Sort: Custom`,
|
||||
- `Group: None`,
|
||||
- `New Task`,
|
||||
- all four column names,
|
||||
- `Backlog summary`.
|
||||
3. Visible seed cards render in expected columns.
|
||||
4. `Review Q3 budget` card opens drawer.
|
||||
5. Drawer shows:
|
||||
- title,
|
||||
- notes,
|
||||
- Work project,
|
||||
- `finance`,
|
||||
- `review`,
|
||||
- `60 min`,
|
||||
- reward/difficulty labels,
|
||||
- suggested slots,
|
||||
- all action buttons.
|
||||
6. Close button closes drawer and clears selected styling.
|
||||
7. Search narrows visible cards and updates state.
|
||||
8. Filter/sort/group menus open without throwing.
|
||||
9. New Task opens create drawer; empty title validation works.
|
||||
10. Command callbacks are invoked for Schedule/Someday/Remove using fakes/mocks.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Widget tests avoid brittle pixel assertions.
|
||||
- Stable keys are used for important controls.
|
||||
- Tests remain readable and scoped.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 9.3 — Forbidden import and boundary tests
|
||||
|
||||
### Tasks
|
||||
|
||||
Update or preserve `test/forbidden_imports_test.dart` so app still cannot import:
|
||||
|
||||
- `scheduler_core/src`,
|
||||
- Drift/SQLite adapters,
|
||||
- backup/export internals,
|
||||
- notification platform internals,
|
||||
- direct OS APIs for this UI slice.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Boundary test passes.
|
||||
- Any new controller/widget files are covered by forbidden import scan.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 9.4 — Responsive and overflow validation
|
||||
|
||||
### Tasks
|
||||
|
||||
Manually or via widget tests inspect Backlog layout at widths:
|
||||
|
||||
- 1672 px reference width,
|
||||
- 1440 px,
|
||||
- 1280 px,
|
||||
- 1100 px if supported.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- desktop reference matches mockup closely,
|
||||
- narrower widths use horizontal board scroll or reduce column widths without overflow,
|
||||
- drawer remains anchored right,
|
||||
- drawer never shifts board elements,
|
||||
- summary can collapse or be hidden below a sensible width if needed.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- No RenderFlex overflow in tested widths.
|
||||
- Drawer overlay behavior remains correct.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 9.5 — Final visual polish pass
|
||||
|
||||
### Tasks
|
||||
|
||||
Compare against both mockups and tune:
|
||||
|
||||
- sidebar active state,
|
||||
- main gutters,
|
||||
- title/count pill,
|
||||
- controls spacing,
|
||||
- column widths/heights,
|
||||
- card density,
|
||||
- icons,
|
||||
- difficulty/reward indicators,
|
||||
- summary panel,
|
||||
- drawer width/spacing,
|
||||
- selected card highlight,
|
||||
- button heights.
|
||||
|
||||
Allowed deviations:
|
||||
|
||||
- minor font metric differences,
|
||||
- safe hit target sizing,
|
||||
- small responsive adjustments,
|
||||
- platform-specific rendering differences.
|
||||
|
||||
Not allowed:
|
||||
|
||||
- white backgrounds,
|
||||
- drawer shifting columns,
|
||||
- cards not data-driven,
|
||||
- missing summary panel,
|
||||
- missing visible action buttons.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Backlog default state and drawer state are close enough for Ashley review.
|
||||
|
||||
BREAKPOINT: Stop here for visual review if any major visual section is missing.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 9.6 — Run validation commands
|
||||
|
||||
### Commands
|
||||
|
||||
From repo root:
|
||||
|
||||
```sh
|
||||
dart format --set-exit-if-changed .
|
||||
dart analyze
|
||||
dart test
|
||||
```
|
||||
|
||||
From Flutter app:
|
||||
|
||||
```sh
|
||||
cd apps/focus_flow_flutter
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
If supported by environment:
|
||||
|
||||
```sh
|
||||
scripts/test.sh
|
||||
```
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Validation passes, or exact environment-related blockers are documented.
|
||||
- New failures are fixed before handoff unless Ashley approves deferral.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 9.7 — Update docs and handoff
|
||||
|
||||
### Tasks
|
||||
|
||||
Update relevant docs:
|
||||
|
||||
- `apps/focus_flow_flutter/README.md` current scope.
|
||||
- `Codex Documentation/Current Software Plan/README.md` if this plan becomes the active plan.
|
||||
- Optional implementation summary in completed plans if the repo tracks completed plan copies after execution.
|
||||
|
||||
Handoff summary must include:
|
||||
|
||||
- files changed,
|
||||
- implementation decisions,
|
||||
- commands run and results,
|
||||
- features implemented,
|
||||
- deferred items,
|
||||
- known risks,
|
||||
- next recommended plan.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- A new developer/Codex run can understand what changed.
|
||||
- Deferred items are explicit, not hidden.
|
||||
|
||||
---
|
||||
|
||||
## Final acceptance checklist
|
||||
|
||||
- [ ] Backlog navigation works.
|
||||
- [ ] Default board matches mockup.
|
||||
- [ ] Drawer state matches mockup.
|
||||
- [ ] Drawer overlays and does not shift layout.
|
||||
- [ ] Four board buckets and counts are data-driven.
|
||||
- [ ] Summary panel is data-driven.
|
||||
- [ ] Search/filter/sort/group controls are safe.
|
||||
- [ ] New Task/Add Task create backlog tasks.
|
||||
- [ ] Schedule uses backend/application command.
|
||||
- [ ] Push to Someday is non-destructive.
|
||||
- [ ] Remove is non-destructive unless Ashley explicitly changed this.
|
||||
- [ ] Break Up works or is explicitly deferred with approval.
|
||||
- [ ] Notes/tags/project metadata is either implemented end-to-end or explicitly deferred with approval.
|
||||
- [ ] Flutter app avoids forbidden imports.
|
||||
- [ ] Tests pass or blockers are documented.
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 — Execution Order
|
||||
|
||||
Follow this order exactly. Stop at each `BREAKPOINT` if Codex is operating in an interactive repo session.
|
||||
|
||||
1. `UI_PLAN_2_BLOCK_01_REPO_SCOPE_AND_ASSETS.md`
|
||||
2. `UI_PLAN_2_BLOCK_02_BACKLOG_READ_MODELS_AND_METADATA.md`
|
||||
3. `UI_PLAN_2_BLOCK_03_NAVIGATION_AND_SCREEN_SHELL.md`
|
||||
4. `UI_PLAN_2_BLOCK_04_BOARD_COLUMNS_AND_CARDS.md`
|
||||
5. `UI_PLAN_2_BLOCK_05_SEARCH_FILTER_SORT_GROUP.md`
|
||||
6. `UI_PLAN_2_BLOCK_06_SUMMARY_PANEL.md`
|
||||
7. `UI_PLAN_2_BLOCK_07_TASK_DETAIL_DRAWER.md`
|
||||
8. `UI_PLAN_2_BLOCK_08_COMMANDS_CREATE_AND_ACTIONS.md`
|
||||
9. `UI_PLAN_2_BLOCK_09_TESTING_VALIDATION_HANDOFF.md`
|
||||
|
||||
Minimum validation after each block:
|
||||
|
||||
```sh
|
||||
cd apps/focus_flow_flutter
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
Additional validation after blocks that touch packages:
|
||||
|
||||
```sh
|
||||
dart analyze
|
||||
dart test
|
||||
```
|
||||
|
||||
Full project validation at the end, if available in the working environment:
|
||||
|
||||
```sh
|
||||
scripts/test.sh
|
||||
```
|
||||
|
||||
Commit guidance:
|
||||
|
||||
- Commit after each completed block.
|
||||
- Use conventional commit style.
|
||||
- Example block commits:
|
||||
- `feat(backlog): add board read models`
|
||||
- `feat(ui): add backlog navigation shell`
|
||||
- `feat(ui): render backlog board cards`
|
||||
- `feat(ui): add backlog detail drawer`
|
||||
- `test(ui): cover backlog board interactions`
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 Implementation Notes
|
||||
|
||||
## Block 01 Baseline Map
|
||||
|
||||
1. Sidebar and app shell currently render through
|
||||
`apps/focus_flow_flutter/lib/widgets/sidebar.dart`,
|
||||
`apps/focus_flow_flutter/lib/widgets/app_shell.dart`, and the
|
||||
`FocusFlowHome` parts under `apps/focus_flow_flutter/lib/app/home/`.
|
||||
2. Navigation state is not implemented yet. `Sidebar` hard-codes Today as
|
||||
active, and `FocusFlowHome` owns only Today controllers. UI Plan 2 should add
|
||||
app-level section state in the home/composition layer before rendering the
|
||||
Backlog board.
|
||||
3. Backlog board data needs new public scheduler application read models. The
|
||||
current `BacklogQueryResult` is list-shaped and returns raw `Task` values via
|
||||
`BacklogItemReadModel`, which is not enough for board columns, summary,
|
||||
detail drawer metadata, or suggested slot previews.
|
||||
4. Existing commands include quick capture to backlog, schedule backlog item to
|
||||
next available slot, move flexible tasks to backlog, remove task, and break
|
||||
up task. Push-to-Someday and non-destructive backlog archive still need
|
||||
command-level decisions before the drawer actions can be final.
|
||||
5. UI Plan 1 components to reuse include `FocusFlowTokens`, the dark shell,
|
||||
timeline reward icon, difficulty bars, selected overlay patterns, and the
|
||||
top-bar icon/segmented-control styling conventions.
|
||||
|
||||
## Block 01 Validation
|
||||
|
||||
- `scripts/bootstrap_dev.sh`: passed.
|
||||
- `dart analyze`: passed.
|
||||
- `dart test`: passed.
|
||||
- `flutter analyze` in `apps/focus_flow_flutter`: passed.
|
||||
- `flutter test` in `apps/focus_flow_flutter`: passed.
|
||||
|
||||
## Block 02 Audit
|
||||
|
||||
1. `GetBacklogRequest`, `BacklogQueryResult`, and `BacklogItemReadModel` remain
|
||||
the compatibility path for the existing simple backlog pane.
|
||||
2. UI Plan 2 should add board-specific public read models instead of extending
|
||||
`BacklogQueryResult`, because the board needs columns, summary
|
||||
distributions, child previews, detail rows, and suggested slots while the
|
||||
current list result intentionally exposes raw tasks plus staleness markers.
|
||||
3. The board bucket policy belongs in pure scheduler backlog code under
|
||||
`packages/scheduler_core/lib/src/scheduling/backlog/`, with Flutter consuming
|
||||
only public `scheduler_core.dart` exports.
|
||||
|
||||
## Block 02 Notes/Tags Breakpoint
|
||||
|
||||
Adding real V1 task notes and freeform tags is a broad persistence change, not
|
||||
only a drawer read-model change. It would touch at least:
|
||||
|
||||
1. `Task`, `Task.quickCapture`, and `Task.copyWith`.
|
||||
2. Task document fields and `TaskDocumentMapping`.
|
||||
3. Persistence contract field sets and document field coverage tests.
|
||||
4. In-memory application/repository round trips through the full domain object.
|
||||
5. SQLite Drift task table columns, generated database code, schema versioning,
|
||||
migration strategy, migration tests, and both SQLite task mappers.
|
||||
6. Command/capture surfaces if the UI can create or edit notes/tags.
|
||||
|
||||
Per `UI_PLAN_2_BLOCK_02_BACKLOG_READ_MODELS_AND_METADATA.md`, this is the
|
||||
breakpoint where Ashley needs to choose whether drawer tags should be read-only
|
||||
placeholders for now or true V1 metadata with a schema migration.
|
||||
|
||||
Ashley chose the read-only path for UI Plan 2. Backlog drawer notes remain
|
||||
`null`, and tags are derived only from existing narrow backlog metadata for now
|
||||
(`BacklogTag.wishlist` displays as `someday`). True notes and freeform tags are
|
||||
tracked in `Human Documentation/Things to consider todo/Backlog Notes and
|
||||
Freeform Tags Metadata.md` for a later migration-backed feature.
|
||||
|
||||
## Block 02 Board and Detail Queries
|
||||
|
||||
1. `GetBacklogBoardRequest` and `GetBacklogTaskDetailRequest` now expose the
|
||||
public query inputs needed by Flutter without importing scheduler `src/`.
|
||||
2. `V1ApplicationManagementUseCases.getBacklogBoard` loads backlog candidates,
|
||||
project display metadata, direct child previews, search/filter/sort/group
|
||||
state, bucket policy results, and summary distributions through repository
|
||||
read boundaries only.
|
||||
3. `V1ApplicationManagementUseCases.getBacklogTaskDetail` returns the selected
|
||||
backlog task drawer model, read-only derived tags, project options, and
|
||||
non-mutating suggested slot previews. Missing duration returns a display
|
||||
reason instead of attempting scheduling.
|
||||
4. Focused application-management tests cover stable empty columns, bucket and
|
||||
summary consistency, search/filter behavior, child previews, not-found detail
|
||||
responses, read-only slot suggestions, and missing-duration handling.
|
||||
|
||||
## Block 02 Demo Seed Data
|
||||
|
||||
1. The Flutter demo composition now seeds Backlog board data as real scheduler
|
||||
`Task` and `ProjectProfile` records, not widget-local mock rows.
|
||||
2. The seed matches the feasible textual targets from Chunk 2.7: 24 backlog
|
||||
tasks, project counts of Home 5 / Work 8 / Personal 5 / Learning 4 / Side
|
||||
Project 2, priority counts of High 7 / Medium 9 / Low 8, and 12h 15m total
|
||||
estimated time.
|
||||
3. The plan/mockup contains a count conflict: the mockup summary says 24 tasks,
|
||||
while the listed column badge counts add to 22. The current seed keeps 24
|
||||
repository-backed backlog tasks so the summary target stays truthful through
|
||||
public read models.
|
||||
4. `Review Q3 budget` is seeded with Work, 60 minutes, High priority,
|
||||
medium reward, and easy difficulty. Finance/review freeform tags, notes text,
|
||||
and exact drawer slot copy remain deferred with the approved metadata scope
|
||||
decision above.
|
||||
|
||||
## Block 03 Navigation and Screen Shell
|
||||
|
||||
1. `FocusFlowSection` now owns app-level navigation state for Today, Backlog,
|
||||
Projects, Reports, and Settings. The sidebar is controlled by this state and
|
||||
exposes stable active keys for Today and Backlog tests.
|
||||
2. `BacklogBoardController` loads `BacklogBoardQueryResult`, tracks local
|
||||
search/filter/sort/group values, and keeps selected task detail as UI-local
|
||||
state. It uses only public scheduler application read models.
|
||||
3. Demo and persistent compositions both create the Backlog controller from the
|
||||
same underlying application store used by Today, so future commands can
|
||||
refresh both surfaces without creating separate fake state.
|
||||
4. `BacklogBoardScreen` renders the desktop Backlog header, controls, column
|
||||
shells, loading/empty/failure states, and a compact summary panel shell.
|
||||
Detailed cards, charts, and drawer contents remain for later UI Plan 2
|
||||
blocks.
|
||||
|
||||
## Block 04 Board Columns and Cards
|
||||
|
||||
1. Backlog bucket, priority, project, reward, and future tag-chip colors now
|
||||
live in `apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart`
|
||||
instead of inline card mappings.
|
||||
2. `BacklogBoardScreen` delegates column rendering to reusable board column and
|
||||
task card widgets. The board keeps the summary panel fixed on the right and
|
||||
horizontally scrolls the four-column board if the available desktop width is
|
||||
constrained.
|
||||
3. `BacklogTaskCard` renders only public `BacklogBoardItemReadModel` data:
|
||||
title, subtitle or bucket reason, duration, project dot/name, priority flag,
|
||||
difficulty bars, solid reward marks, selected outline, and direct child
|
||||
previews.
|
||||
4. Add-task controls intentionally call a no-op bucket callback until Block 8
|
||||
wires the create-task command flow. Card taps select through
|
||||
`BacklogBoardController.selectTask`, which performs a read-only detail query.
|
||||
5. The current demo seed still represents mockup child rows as normal backlog
|
||||
tasks rather than parent-child relationships. Child preview rendering is
|
||||
covered by a focused widget test using handcrafted public read models so the
|
||||
seed scope does not expand in this block.
|
||||
|
||||
## Block 05 Search, Filter, Sort, and Group Controls
|
||||
|
||||
1. The board request now has `BacklogBoardFilterSelection` for board-only
|
||||
filters: priority group, project, duration bucket, board bucket, and missing
|
||||
duration. Legacy `BacklogFilter.noRewardSet` remains the cleanup filter for
|
||||
no-reward tasks.
|
||||
2. `BacklogSortKey.custom` preserves source/read-model order for the board
|
||||
default, and `BacklogSortKey.duration` supports duration ordering. The simple
|
||||
backlog list still keeps its existing priority default.
|
||||
3. `BacklogBoardGroupMode` now supports project, priority, and duration
|
||||
grouping as stable ordering inside the existing four bucket columns. The UI
|
||||
does not add section dividers in this block.
|
||||
4. The Backlog control row lives in
|
||||
`apps/focus_flow_flutter/lib/widgets/backlog/backlog_board_controls.dart`.
|
||||
Search reloads through the board query, filters apply through the public
|
||||
request object, sort/group menus update controller state, and Compact,
|
||||
Settings, and New Task remain safe inert shells until later blocks.
|
||||
|
||||
## Block 06 Summary Panel
|
||||
|
||||
1. `BacklogSummaryPanel` now owns the right-side summary surface and consumes
|
||||
only `BacklogBoardSummaryReadModel` data from the public scheduler API.
|
||||
2. The panel renders total tasks, total estimated time, priority distribution,
|
||||
project distribution, a custom-painted duration donut with legend rows, and
|
||||
the planning tip card without hardcoded summary values.
|
||||
3. Summary panel color decisions use
|
||||
`apps/focus_flow_flutter/lib/theme/backlog_board_visual_tokens.dart` for
|
||||
priority, project, and duration colors so board cards and summary rows share
|
||||
the same visual mapping.
|
||||
4. Collapse and expand state is local to the Flutter widget. It hides body
|
||||
sections while leaving the summary header visible and does not write any
|
||||
scheduler state.
|
||||
|
||||
## Block 07 Task Detail Drawer
|
||||
|
||||
1. `BacklogBoardController` now keeps selected-task id, detail loading state,
|
||||
and detail-read failures as UI-local state. Detail failures render in the
|
||||
drawer and no longer replace the whole Backlog screen.
|
||||
2. `BacklogBoardScreen` renders `BacklogTaskDrawer` in a right-anchored
|
||||
`Stack` overlay. The board and summary row stay in their original layout, so
|
||||
opening and closing the drawer does not shift cards.
|
||||
3. `BacklogTaskDrawer` renders header metadata, empty or read-only notes,
|
||||
project display, read-only tags, duration/reward/difficulty metrics,
|
||||
suggested slot previews, and Block-8-ready action buttons from public
|
||||
`BacklogTaskDetailReadModel` data.
|
||||
4. Real notes and freeform tags remain deferred per the Block 02 metadata
|
||||
decision. The drawer shows calm empty states when the read model has no
|
||||
notes or tags instead of inventing Flutter-only persisted metadata.
|
||||
|
||||
## Block 08 Commands Through Drawer Actions
|
||||
|
||||
1. Metadata edit controls remain safe non-mutating placeholders at this point
|
||||
because real notes and freeform tags were explicitly deferred in Block 02.
|
||||
Project and tag editing should be revisited with a migration-backed metadata
|
||||
command rather than Flutter-only state.
|
||||
2. `BacklogBoardScreen` now accepts the app `SchedulerCommandController`.
|
||||
Backlog command state is rendered in a compact status banner below the
|
||||
controls.
|
||||
3. Top-level `New Task` and column `Add task` open a title-first creation
|
||||
dialog. Successful submission calls the existing `quickCaptureToBacklog`
|
||||
command and refreshes Backlog through the shared command controller.
|
||||
4. Drawer `Schedule` calls the existing
|
||||
`scheduleBacklogItemToNextAvailableSlot` command through
|
||||
`SchedulerCommandController.scheduleBacklogItem`. Missing duration shows a
|
||||
drawer-local message and does not start a command.
|
||||
5. Drawer `Break Up` opens a focused child-entry dialog with two initial rows.
|
||||
The dialog validates at least two titled rows, optional whole-minute
|
||||
estimates, and submits `ApplicationChildTaskDraft` values through the
|
||||
existing `breakUpTask` application command. Successful saves refresh the
|
||||
board and selected detail so child previews come from scheduler read models.
|
||||
6. Drawer `Push to Someday` now uses a Backlog-specific application command
|
||||
that adds the existing `BacklogTag.wishlist` flag without scheduling or
|
||||
deleting the task. The selected detail and board refresh into the `Not Now`
|
||||
bucket.
|
||||
7. Drawer `Remove` now confirms with the requested copy and archives the task
|
||||
through a Backlog-specific application command. It marks the task
|
||||
`TaskStatus.noLongerRelevant`, clears active Backlog selection on success,
|
||||
and does not physically delete repository data.
|
||||
8. The shared application-command change detector now includes Backlog metadata
|
||||
such as `backlogTags`, so metadata-only commands are persisted even when the
|
||||
command clock equals the task's previous `updatedAt`.
|
||||
|
||||
## Block 09 Validation and Handoff
|
||||
|
||||
1. Added final widget coverage for the default Backlog shell controls, empty
|
||||
New Task title validation, and responsive no-overflow smoke checks at 1672,
|
||||
1440, 1280, and 1100 px desktop widths.
|
||||
2. Added a SQLite schema-column regression test and kept Drift table DSL input
|
||||
files out of the custom runtime coverage denominator. The fresh coverage
|
||||
gate reports 80.34% package `lib/` line coverage.
|
||||
3. Preserved the forbidden-import boundary test so Flutter widgets and
|
||||
controllers continue to use public scheduler APIs instead of scheduler
|
||||
internals, Drift, SQLite adapters, notification internals, backup/export
|
||||
internals, or direct OS APIs outside approved runtime boundaries.
|
||||
4. Final validation passed with:
|
||||
- `scripts/test.sh`
|
||||
- `cd apps/focus_flow_flutter && flutter analyze`
|
||||
- `cd apps/focus_flow_flutter && flutter test`
|
||||
5. Manual live UI review was not possible in this session because the local X11
|
||||
display was refusing new clients with `Maximum number of clients reached`.
|
||||
The diagnosed cause was many duplicate `xbindkeys` clients, not a Flutter
|
||||
build failure.
|
||||
6. Deferred items remain limited to the approved metadata scope: real notes,
|
||||
freeform tags, project edits, and the Backlog drawer `View day` shortcut.
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# UI Plan 2 — Backlog Board Tab Implementation
|
||||
|
||||
**Status:** Proposed plan.
|
||||
**Primary target:** Implement the finalized Backlog tab shown in the two supplied mockups.
|
||||
**Default mockup:** `mockups/backlog_board_default.png`.
|
||||
**Drawer mockup:** `mockups/backlog_task_drawer.png`.
|
||||
**Execution mode:** Feed this folder to Codex and execute block files in numeric order. Use `xhigh` reasoning for all chunks unless Ashley explicitly says otherwise.
|
||||
**Implementation branch suggestion:** `block-ui-backlog-board` or the 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.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Backlog Board Mockups
|
||||
|
||||
These images are the visual references for UI Plan 2.
|
||||
|
||||
- `backlog_board_default.png` — finalized default Backlog board state with summary panel.
|
||||
- `backlog_task_drawer.png` — finalized selected-task state with right-anchored drawer.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Treat these as semi-strict visual references.
|
||||
- Preserve the dark theme, board density, summary panel, and drawer anchoring.
|
||||
- The drawer must overlay existing content and must not move board columns or summary layout.
|
||||
- Do not introduce a white app background.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
|
|
@ -1,35 +1,59 @@
|
|||
# V1 ADR 002: SQLite Document Schema V1
|
||||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# V1 ADR 002: SQLite Document Schema V2
|
||||
|
||||
Status: **Accepted; updated to match current app state**
|
||||
|
||||
Status: **Accepted**
|
||||
Date: 2026-06-26
|
||||
|
||||
Last reviewed: 2026-07-07
|
||||
|
||||
## Context
|
||||
The project pivoted from MongoDB to **SQLite‑first** persistence.
|
||||
We need a stable, versioned relational schema that maps cleanly to the domain
|
||||
objects while staying behind the repository abstraction. Drift will manage
|
||||
migrations.
|
||||
|
||||
The project is SQLite-first. The local app persists scheduler state in a Drift
|
||||
database and keeps storage details behind repository and application-unit-of-work
|
||||
boundaries.
|
||||
|
||||
The original V1 schema was implemented and then migrated to the current Drift
|
||||
schema version 2 when application-layer records became durable state.
|
||||
|
||||
## Decision
|
||||
* **Database file**: `adhd_scheduler.sqlite` in the user data directory.
|
||||
* **Schema version**: `1` (managed by Drift).
|
||||
|
||||
* **Database file**: `scheduler.sqlite` under `~/ADHD_Scheduler/` by default.
|
||||
Runtime composition may override this with `SCHEDULER_SQLITE_PATH`, and dev
|
||||
tooling may pass `--sqlite`.
|
||||
* **Schema version**: `2`, managed by Drift. Version 1 files are supported as
|
||||
migration inputs; downgrades are not supported.
|
||||
* **Tables**
|
||||
|
||||
| Table | Purpose | Key fields |
|
||||
|-------|---------|------------|
|
||||
| `tasks` | Authoritative task rows | `id TEXT PRIMARY KEY`, `owner_id TEXT`, `project_id`, `parent_id`, `type`, `status`, `priority`, `reward`, `difficulty`, `scheduled_start_utc`, `scheduled_end_utc`, `actual_start_utc`, `actual_end_utc`, `completed_at_utc`, `revision INT`, `created_at_utc`, `updated_at_utc`, `backlog_entered_at_utc`, `backlog_entered_provenance TEXT` |
|
||||
| `projects` | Project configuration | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `color_key`, config defaults …, `archived_at_utc`, `revision` |
|
||||
| `locked_blocks` | Recurring / one‑off locked time | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `date TEXT`, `start_time TEXT`, `end_time TEXT`, `recurrence_json`, `hidden_by_default INT`, `archived_at_utc`, `revision` |
|
||||
| `locked_overrides` | Date‑scoped overrides | `id TEXT PRIMARY KEY`, `owner_id`, `locked_block_id`, `date TEXT`, `type`, JSON fields |
|
||||
| `settings` | One row per owner | `owner_id TEXT PRIMARY KEY`, `timezone_id`, `day_start_minutes`, `day_end_minutes`, `compact_mode INT`, `revision` |
|
||||
| `activities` | Append‑only internal events | `id TEXT PRIMARY KEY`, `owner_id`, `task_id`, `code`, `occurred_at_utc`, `metadata_json` |
|
||||
| `snapshots` | Bounded diagnostic schedule snapshots | `id TEXT PRIMARY KEY`, `owner_id`, `operation_name`, `source_date TEXT`, `window_json`, `notice_json`, `changes_json`, `retention_expires_utc` |
|
||||
| `tasks` | Authoritative task rows | `id TEXT PRIMARY KEY`, `owner_id`, `title`, `project_id`, `parent_id`, `type`, `status`, `priority`, `reward`, `difficulty`, `duration_minutes`, scheduled/actual/completed UTC timestamps, `backlog_tags_json`, `reminder_override`, `stats_json`, `backlog_entered_at_utc`, `backlog_entered_provenance`, `revision`, `created_at_utc`, `updated_at_utc` |
|
||||
| `task_activities` | Append-only task activity facts | `id TEXT PRIMARY KEY`, `owner_id`, `task_id`, `project_id`, `operation_id`, `code`, `occurred_at_utc`, `metadata_json` |
|
||||
| `projects` | Project configuration | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `color_key`, configured defaults, `archived_at_utc`, `revision`, `created_at_utc`, `updated_at_utc` |
|
||||
| `project_statistics` | Project-level aggregate statistics | `project_id PRIMARY KEY`, `owner_id`, completion and suggestion-count fields, `revision`, `created_at_utc`, `updated_at_utc` |
|
||||
| `locked_blocks` | Recurring / one-off locked time | `id TEXT PRIMARY KEY`, `owner_id`, `name`, `date`, `start_time`, `end_time`, `recurrence_json`, `hidden_by_default`, `project_id`, `archived_at_utc`, `revision`, timestamps |
|
||||
| `locked_overrides` | Date-scoped locked-block overrides | `id TEXT PRIMARY KEY`, `owner_id`, `locked_block_id`, `date`, override type and payload fields |
|
||||
| `settings` | One row per owner | `owner_id TEXT PRIMARY KEY`, `timezone_id`, `day_start_minutes`, `day_end_minutes`, `compact_mode`, `backlog_staleness_json`, `revision`, timestamps |
|
||||
| `snapshots` | Bounded diagnostic schedule snapshots | `id TEXT PRIMARY KEY`, `owner_id`, `captured_at_utc`, `operation_name`, `source_date`, `target_date`, JSON payload columns, `retention_expires_utc`, `truncated`, `revision`, timestamps |
|
||||
| `application_operations` | Committed operation records for idempotency | `operation_id TEXT PRIMARY KEY`, `owner_id`, `operation_name`, `committed_at_utc` |
|
||||
| `notice_acknowledgements` | Owner-scoped acknowledged notice records | composite key `owner_id, notice_id`, `acknowledged_at_utc`, `revision`, timestamps |
|
||||
|
||||
* All timestamps are stored as **UTC**.
|
||||
* Optimistic concurrency: every mutable row carries `revision`. Updates must
|
||||
include `WHERE revision = :expected` and increment on success.
|
||||
* Drift migrations are generated and tested; downgrades are not supported.
|
||||
* UTC timestamps are stored in Drift `DateTime` columns. Civil dates and wall
|
||||
times remain text fields where the domain model requires calendar-local values.
|
||||
* Mutable domain/application records carry optimistic `revision` values. Updates
|
||||
must compare the expected revision and increment on success. Append-only
|
||||
activity and operation records are immutable facts and do not use mutable-save
|
||||
revision checks.
|
||||
* Schema version 2 migrations create the application-layer tables and indexes
|
||||
needed for task activity, project statistics, idempotent operations, and notice
|
||||
acknowledgements.
|
||||
|
||||
## Consequences
|
||||
* Domain objects remain unchanged; adapters translate.
|
||||
* Backup library (Block 24) can copy and encrypt this single file.
|
||||
* Repository contract tests assert schema integrity via Drift introspection.
|
||||
|
||||
* Domain objects remain unchanged; adapters translate between domain objects and
|
||||
SQLite rows.
|
||||
* The backup library copies and encrypts this single SQLite file.
|
||||
* Repository conformance tests cover adapter behavior, while Drift schema tests
|
||||
verify version 2 tables and version 1 upgrade behavior.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,33 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# V1 ADR 005: Export & Backup Boundary
|
||||
|
||||
Status: **Accepted**
|
||||
Status: **Accepted; updated to match current app state**
|
||||
|
||||
Date: 2026-06-26
|
||||
|
||||
* **Backup** – passphrase‑encrypted SQLite copy (`.sqlite.aes` via AES‑256‑GCM, PBKDF2 200 k rounds).
|
||||
* **Readable export** – JSON and CSV via `ExportController`.
|
||||
Last reviewed: 2026-07-07
|
||||
|
||||
## Decision
|
||||
|
||||
* **Backup**: passphrase-encrypted copy of the local SQLite database bytes.
|
||||
Defaults:
|
||||
* source DB: `~/ADHD_Scheduler/scheduler.sqlite`
|
||||
* backup directory: `~/ADHD_Scheduler/backups`
|
||||
* backup filename: `yyyymmdd_hhmm.db.enc`
|
||||
* **Backup cryptography**: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation,
|
||||
200,000 iterations, random salt, and random nonce. The encoded file is a JSON
|
||||
envelope containing format metadata, salt, nonce, ciphertext, and MAC.
|
||||
* **Restore**: decrypts a `.db.enc` backup and atomically replaces the target
|
||||
SQLite file.
|
||||
* **Readable export**: JSON and CSV are produced through `ExportController` and
|
||||
export writer implementations.
|
||||
|
||||
## Boundary
|
||||
|
||||
Encrypted backup is the restorable binary preservation path for the SQLite
|
||||
runtime database. JSON and CSV exports are readable user/data-portability outputs
|
||||
and are not the canonical restore format.
|
||||
|
||||
No open questions remain.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,32 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# V1 ADR 006: Schedule Snapshot Policy
|
||||
|
||||
Status: **Accepted**
|
||||
Status: **Accepted; updated to match current app state**
|
||||
|
||||
Date: 2026-06-26
|
||||
|
||||
Persist committed diagnostic snapshots only; bounded retention 30 days or until notices acknowledged.
|
||||
Last reviewed: 2026-07-07
|
||||
|
||||
No open questions remain.
|
||||
## Decision
|
||||
|
||||
Persist committed diagnostic schedule snapshots only. Snapshots are diagnostic
|
||||
records, not authoritative task state.
|
||||
|
||||
Snapshot retention is bounded by a `retention_expires_utc` timestamp derived from
|
||||
the snapshot capture time plus 30 days. Repository cleanup deletes snapshots whose
|
||||
retention expiry is before the cleanup instant.
|
||||
|
||||
Notice acknowledgement is stored separately in `notice_acknowledgements`. The
|
||||
Today read model suppresses acknowledged notices when presenting pending notice
|
||||
state, but acknowledgement does not delete the source snapshot early.
|
||||
|
||||
## Consequences
|
||||
|
||||
* Snapshot rows can remain available for diagnostics until their retention
|
||||
expiry even after all displayed notices have been acknowledged.
|
||||
* Notice acknowledgement is an application-layer read-model concern, not a
|
||||
snapshot lifecycle rule.
|
||||
* Cleanup jobs and tests should target `retention_expires_utc`, not notice
|
||||
acknowledgement status.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,43 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# V1 ADR 008: Notification Abstraction
|
||||
|
||||
Status: **Accepted**
|
||||
Status: **Accepted; updated to match current app state**
|
||||
|
||||
Date: 2026-06-26
|
||||
|
||||
`NotificationAdapter` interface abstracts scheduling/cancellation and feedback stream. Desktop implementation in `scheduler_notifications_desktop`; Fake adapter for tests.
|
||||
Last reviewed: 2026-07-07
|
||||
|
||||
No open questions remain.
|
||||
## Decision
|
||||
|
||||
`NotificationAdapter` in `scheduler_notifications` is the platform-neutral
|
||||
boundary for reminder delivery. It exposes:
|
||||
|
||||
* `schedule(NotificationRequest request)`
|
||||
* `cancel(String id)`
|
||||
* `Stream<NotificationFeedback> feedback`
|
||||
|
||||
The pure scheduler core computes reminder policy and directive values only. It
|
||||
does not import desktop, OS, process, or notification APIs.
|
||||
|
||||
The test fake, `FakeNotificationAdapter`, stores scheduled requests,
|
||||
normalizes/captures cancellations, and can emit feedback events for workflow
|
||||
tests.
|
||||
|
||||
The desktop implementation lives in `scheduler_notifications_desktop`.
|
||||
It delegates to platform backends:
|
||||
|
||||
* Linux: `notify-send`
|
||||
* macOS: `osascript`
|
||||
* Windows and unsupported platforms: stdout fallback
|
||||
|
||||
The current desktop adapter satisfies the feedback-stream contract with an empty
|
||||
stream. OS-level click/action feedback is not implemented in V1.
|
||||
|
||||
## Consequences
|
||||
|
||||
* Reminder delivery remains replaceable at the composition boundary.
|
||||
* Core tests can use the fake adapter without depending on platform APIs.
|
||||
* Future OS feedback support must extend the desktop package without changing
|
||||
the scheduler core boundary.
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,13 +0,0 @@
|
|||
# ADHD-Friendly Scheduling Application
|
||||
|
||||
Unified Product and Design Specification
|
||||
|
||||
Draft compiled from user requirements - June 19, 2026
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This application is a failure-tolerant scheduling system for people who struggle with executive dysfunction, task migration, reminders, volatile schedules, and missed logging. It is not intended to be a generic productivity app. Its core job is to preserve intent when the plan breaks: move flexible tasks forward, protect required commitments, keep locked unavailable time out of the way visually, and make recovery simple instead of punitive.
|
||||
|
||||
The MVP focuses on Today, Backlog, recurring hidden locked blocks, push/rollover behavior, task capture, and simple state management. Version 2.0 adds week/month views, reports, drag-and-drop, task history, and the full overwhelm shield/recovery system.
|
||||
|
||||
See the DOCX file for the complete formatted specification.
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# Human Documentation
|
||||
|
||||
This folder contains human-facing product documentation.
|
||||
|
||||
Start with:
|
||||
|
||||
- `Overall App Design Spec.docx`
|
||||
- `Original Chat-Compiled Design Spec.md`
|
||||
|
||||
These documents define the product intent, terminology, major features, and version boundaries. Codex should use these files for product context but should execute from the files in `Codex Documentation/Current Software Plan/`.
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Backlog Notes and Freeform Tags Metadata
|
||||
|
||||
## Status
|
||||
|
||||
Deferred from UI Plan 2 on 2026-07-07.
|
||||
|
||||
For the current Backlog board implementation, notes and freeform tags are
|
||||
read-only presentation placeholders. Do not expand the current UI Plan 2 scope
|
||||
to add persisted task notes or general-purpose tags.
|
||||
|
||||
## Why this is deferred
|
||||
|
||||
The drawer mockup includes notes and tag chips, but the current V1 `Task` domain
|
||||
model only has narrow `BacklogTag` flags such as `wishlist`. It does not yet
|
||||
have general freeform notes or user-defined task tags.
|
||||
|
||||
Adding those fields correctly is not a local widget change. It is a real V1
|
||||
metadata and persistence feature with schema, migration, command, and test
|
||||
impact.
|
||||
|
||||
## Work left for the future feature
|
||||
|
||||
When this feature is resumed, decide the exact product behavior first:
|
||||
|
||||
1. Whether notes are plain text only or support lightweight formatting later.
|
||||
2. Whether tags are task-local strings or reusable global tag records.
|
||||
3. Maximum note length.
|
||||
4. Maximum tag count per task.
|
||||
5. Maximum tag label length.
|
||||
6. Case and duplicate handling for tags.
|
||||
7. Whether tag edits are allowed in quick capture, the Backlog drawer, or both.
|
||||
8. Whether existing imports/exports should include notes and tags immediately.
|
||||
|
||||
Expected implementation areas:
|
||||
|
||||
1. Add `String? notes` and either `Set<String> tags` or a small validated tag
|
||||
value object to `Task`.
|
||||
2. Update `Task.quickCapture`, `Task.copyWith`, and domain invariant tests.
|
||||
3. Add validation and normalization for blank notes, blank tags, duplicate tags,
|
||||
and safe payload limits.
|
||||
4. Extend task document fields and `TaskDocumentMapping`.
|
||||
5. Update document mapping, migration, and persistence field coverage tests.
|
||||
6. Update repository conformance expectations so notes/tags round-trip through
|
||||
every adapter.
|
||||
7. Add SQLite Drift task columns or a normalized tag table, depending on the
|
||||
chosen tag model.
|
||||
8. Bump or migrate SQLite schema as required and add migration tests for old
|
||||
databases.
|
||||
9. Regenerate Drift code.
|
||||
10. Update SQLite application and repository task row mappers.
|
||||
11. Extend quick-capture/create/update command surfaces if users can write notes
|
||||
or tags.
|
||||
12. Update readable export/import behavior if these fields are part of user data
|
||||
portability.
|
||||
|
||||
## Current UI Plan 2 handling
|
||||
|
||||
For UI Plan 2, the Backlog board and drawer may display placeholder or derived
|
||||
read-only tag text where useful, but Flutter must not pretend those values are
|
||||
persisted editable task metadata. Editing controls should remain disabled,
|
||||
hidden, or clearly non-editing until the future metadata feature lands.
|
||||
|
|
@ -11,10 +11,19 @@ The app launches into the compact Today timeline. Normal startup now opens an
|
|||
on-disk SQLite database and bootstraps owner settings plus the default Home
|
||||
project when they are missing.
|
||||
|
||||
The compact Today screen can toggle completion for currently rendered task
|
||||
cards. Backlog widgets and quick-capture controls exist for tests and focused
|
||||
composition paths, but the sidebar Backlog navigation remains out of scope for
|
||||
this screen.
|
||||
cards. The sidebar Backlog navigation opens the board surface backed by public
|
||||
scheduler application APIs. Backlog supports the four board buckets, search,
|
||||
filters, sort/group controls, summary data, the right-anchored detail drawer,
|
||||
task creation, scheduling, Break Up child-task creation, Someday tagging, and
|
||||
non-destructive archive/remove from active Backlog.
|
||||
|
||||
Backlog notes, project edits, and freeform tag edits intentionally remain
|
||||
read-only or non-mutating placeholders until the deferred metadata migration and
|
||||
application command are implemented.
|
||||
|
||||
The sidebar Settings item and top bar Settings button both open a dialog bound
|
||||
to persisted `OwnerSettings`/`BacklogStalenessSettings`, covering time zone, day
|
||||
window, compact-mode toggle, and backlog staleness thresholds.
|
||||
|
||||
## Runtime Data
|
||||
|
||||
|
|
@ -102,13 +111,11 @@ boundary.
|
|||
|
||||
## Intentionally No-op
|
||||
|
||||
- Sidebar navigation.
|
||||
- Date navigation and calendar buttons.
|
||||
- Compact/Normal toggle.
|
||||
- Settings button.
|
||||
- Compact/Normal toggle (Settings persists the flag, but the Today pipeline
|
||||
does not yet read it; see Forgejo issue #11).
|
||||
- Show upcoming.
|
||||
- Timeline card action icons.
|
||||
- Modal Push, Move to Backlog, and Break up buttons.
|
||||
- Timeline card Schedule and Protect time icons.
|
||||
- Backlog drawer project selector, add-tag control, and View day shortcut.
|
||||
|
||||
## Persistence Proof
|
||||
|
||||
|
|
@ -171,7 +178,8 @@ Run these from `apps/focus_flow_flutter/`.
|
|||
|
||||
## Next Plans
|
||||
|
||||
- Wire visible Backlog navigation and quick-capture UI into the main shell.
|
||||
- Wire functional Push, Move to Backlog, and Break up actions.
|
||||
- Add real navigation/settings.
|
||||
- Give the Compact/Normal toggle real backing state (the live Today pipeline
|
||||
has no compact-mode concept yet; see Forgejo issue #11).
|
||||
- Add migration-backed notes, project edits, and freeform tags for Backlog
|
||||
metadata.
|
||||
- Add Shield/Recovery only in a later scope.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,30 @@
|
|||
|
||||
part of '../demo_scheduler_composition.dart';
|
||||
|
||||
/// Top-level helper that performs the `_demoProjects` operation for this file.
|
||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||
List<ProjectProfile> _demoProjects() {
|
||||
return [
|
||||
ProjectProfile(id: 'home', name: 'Home', colorKey: 'project-home'),
|
||||
ProjectProfile(id: 'work', name: 'Work', colorKey: 'project-work'),
|
||||
ProjectProfile(
|
||||
id: 'personal',
|
||||
name: 'Personal',
|
||||
colorKey: 'project-personal',
|
||||
),
|
||||
ProjectProfile(
|
||||
id: 'learning',
|
||||
name: 'Learning',
|
||||
colorKey: 'project-learning',
|
||||
),
|
||||
ProjectProfile(
|
||||
id: 'side-project',
|
||||
name: 'Side Project',
|
||||
colorKey: 'project-side-project',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Top-level helper that performs the `_todaySeedTasks` operation for this file.
|
||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||
List<Task> _todaySeedTasks(CivilDate date, DateTime createdAt) {
|
||||
|
|
@ -123,6 +147,284 @@ List<Task> _todaySeedTasks(CivilDate date, DateTime createdAt) {
|
|||
];
|
||||
}
|
||||
|
||||
/// Top-level helper that performs the `_backlogSeedTasks` operation for this file.
|
||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||
List<Task> _backlogSeedTasks(CivilDate date, DateTime createdAt) {
|
||||
return [
|
||||
_backlogSeedTask(
|
||||
id: 'reply-to-3-emails',
|
||||
title: 'Reply to 3 emails',
|
||||
projectId: 'home',
|
||||
order: 1,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
durationMinutes: 10,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'update-project-status',
|
||||
title: 'Update project status',
|
||||
projectId: 'work',
|
||||
order: 2,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
durationMinutes: 15,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'schedule-dentist-appt',
|
||||
title: 'Schedule dentist appt',
|
||||
projectId: 'personal',
|
||||
order: 3,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
durationMinutes: 10,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'clean-up-downloads-folder',
|
||||
title: 'Clean up downloads folder',
|
||||
projectId: 'home',
|
||||
order: 4,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
durationMinutes: 10,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'plan-tomorrow',
|
||||
title: 'Plan tomorrow',
|
||||
projectId: 'work',
|
||||
order: 5,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
durationMinutes: 10,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'review-q3-budget',
|
||||
title: 'Review Q3 budget',
|
||||
projectId: 'work',
|
||||
order: 6,
|
||||
priority: PriorityLevel.high,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
durationMinutes: 60,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'plan-next-week',
|
||||
title: 'Plan next week',
|
||||
projectId: 'work',
|
||||
order: 7,
|
||||
priority: PriorityLevel.high,
|
||||
reward: RewardLevel.high,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 30,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'define-weekly-goals',
|
||||
title: 'Define weekly goals',
|
||||
projectId: 'work',
|
||||
order: 8,
|
||||
priority: PriorityLevel.high,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 15,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'time-block-schedule',
|
||||
title: 'Time block schedule',
|
||||
projectId: 'work',
|
||||
order: 9,
|
||||
priority: PriorityLevel.high,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 15,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'organize-kitchen-drawers',
|
||||
title: 'Organize kitchen drawers',
|
||||
projectId: 'home',
|
||||
order: 10,
|
||||
priority: PriorityLevel.low,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 45,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'renew-car-registration',
|
||||
title: 'Renew car registration',
|
||||
projectId: 'home',
|
||||
order: 11,
|
||||
priority: PriorityLevel.high,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 25,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'build-analytics-dashboard',
|
||||
title: 'Build analytics dashboard',
|
||||
projectId: 'work',
|
||||
order: 12,
|
||||
priority: PriorityLevel.high,
|
||||
reward: RewardLevel.high,
|
||||
difficulty: DifficultyLevel.hard,
|
||||
durationMinutes: 120,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'write-marketing-strategy',
|
||||
title: 'Write marketing strategy',
|
||||
projectId: 'work',
|
||||
order: 13,
|
||||
priority: PriorityLevel.high,
|
||||
reward: RewardLevel.high,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 90,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'complete-online-course',
|
||||
title: 'Complete online course',
|
||||
projectId: 'learning',
|
||||
order: 14,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.high,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 90,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'module-1-foundations',
|
||||
title: 'Module 1: Foundations',
|
||||
projectId: 'learning',
|
||||
order: 15,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.hard,
|
||||
durationMinutes: 15,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'module-2-deep-dive',
|
||||
title: 'Module 2: Deep Dive',
|
||||
projectId: 'learning',
|
||||
order: 16,
|
||||
priority: PriorityLevel.low,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.hard,
|
||||
durationMinutes: 15,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'module-3-practice',
|
||||
title: 'Module 3: Practice',
|
||||
projectId: 'learning',
|
||||
order: 17,
|
||||
priority: PriorityLevel.low,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.hard,
|
||||
durationMinutes: 15,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'learn-guitar',
|
||||
title: 'Learn guitar',
|
||||
projectId: 'personal',
|
||||
order: 18,
|
||||
priority: PriorityLevel.low,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 30,
|
||||
backlogTags: const {BacklogTag.wishlist},
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'start-youtube-channel',
|
||||
title: 'Start YouTube channel',
|
||||
projectId: 'side-project',
|
||||
order: 19,
|
||||
priority: PriorityLevel.low,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 20,
|
||||
backlogTags: const {BacklogTag.wishlist},
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'travel-to-japan',
|
||||
title: 'Travel to Japan',
|
||||
projectId: 'personal',
|
||||
order: 20,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 30,
|
||||
backlogTags: const {BacklogTag.wishlist},
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'home-office-upgrade',
|
||||
title: 'Home office upgrade',
|
||||
projectId: 'home',
|
||||
order: 21,
|
||||
priority: PriorityLevel.low,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 20,
|
||||
backlogTags: const {BacklogTag.wishlist},
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'read-2-books',
|
||||
title: 'Read 2 books',
|
||||
projectId: 'personal',
|
||||
order: 22,
|
||||
priority: PriorityLevel.low,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 15,
|
||||
backlogTags: const {BacklogTag.wishlist},
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'sketch-app-icon-ideas',
|
||||
title: 'Sketch app icon ideas',
|
||||
projectId: 'side-project',
|
||||
order: 23,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
durationMinutes: 15,
|
||||
backlogTags: const {BacklogTag.wishlist},
|
||||
createdAt: createdAt,
|
||||
),
|
||||
_backlogSeedTask(
|
||||
id: 'archive-photo-backlog',
|
||||
title: 'Archive photo backlog',
|
||||
projectId: 'personal',
|
||||
order: 24,
|
||||
priority: PriorityLevel.low,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
durationMinutes: 15,
|
||||
backlogTags: const {BacklogTag.wishlist},
|
||||
createdAt: createdAt,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Top-level helper that performs the `_seedTask` operation for this file.
|
||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||
Task _seedTask({
|
||||
|
|
@ -161,3 +463,34 @@ Task _seedTask({
|
|||
updatedAt: completedAt ?? createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Top-level helper that performs the `_backlogSeedTask` operation for this file.
|
||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||
Task _backlogSeedTask({
|
||||
required String id,
|
||||
required String title,
|
||||
required String projectId,
|
||||
required int order,
|
||||
required PriorityLevel priority,
|
||||
required RewardLevel reward,
|
||||
required DifficultyLevel difficulty,
|
||||
required int durationMinutes,
|
||||
required DateTime createdAt,
|
||||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||
}) {
|
||||
final orderedCreatedAt = createdAt.add(Duration(minutes: order));
|
||||
return Task(
|
||||
id: id,
|
||||
title: title,
|
||||
projectId: projectId,
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.backlog,
|
||||
priority: priority,
|
||||
reward: reward,
|
||||
difficulty: difficulty,
|
||||
durationMinutes: durationMinutes,
|
||||
backlogTags: backlogTags,
|
||||
createdAt: orderedCreatedAt,
|
||||
updatedAt: orderedCreatedAt,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ library;
|
|||
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../controllers/backlog_board_controller.dart';
|
||||
import '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/scheduler_read_controller.dart';
|
||||
import '../controllers/selected_date_controller.dart';
|
||||
|
|
@ -91,24 +92,13 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
|||
CivilDate.fromDateTime((clock ?? const SystemClock()).now());
|
||||
final readAt = _instant(resolvedDate, 15, 55);
|
||||
final createdAt = _instant(resolvedDate, 8);
|
||||
final project = ProjectProfile(
|
||||
id: projectId,
|
||||
name: 'Home',
|
||||
colorKey: 'project-home',
|
||||
);
|
||||
final projects = _demoProjects();
|
||||
final tasks = <Task>[
|
||||
if (includeTodayItems) ..._todaySeedTasks(resolvedDate, createdAt),
|
||||
if (includeBacklogItems)
|
||||
Task.quickCapture(
|
||||
id: 'later-backlog-note',
|
||||
title: 'Review reusable grocery list',
|
||||
projectId: projectId,
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
),
|
||||
if (includeBacklogItems) ..._backlogSeedTasks(resolvedDate, createdAt),
|
||||
];
|
||||
final store = InMemoryApplicationUnitOfWork(
|
||||
initialProjects: [project],
|
||||
initialProjects: projects,
|
||||
initialOwnerSettings: [
|
||||
OwnerSettings(
|
||||
ownerId: ownerId,
|
||||
|
|
@ -158,6 +148,39 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
|||
);
|
||||
}
|
||||
|
||||
/// Creates the controller used by the Backlog board screen.
|
||||
@override
|
||||
BacklogBoardController createBacklogBoardController({
|
||||
required SelectedDateController selectedDates,
|
||||
}) {
|
||||
return BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: dateLabelFor,
|
||||
readBoard: (request) {
|
||||
return managementUseCases.getBacklogBoard(
|
||||
GetBacklogBoardRequest(
|
||||
context: context('ui-read-backlog-board'),
|
||||
localDate: request.localDate,
|
||||
searchText: request.searchText,
|
||||
filters: request.filters,
|
||||
boardFilters: request.boardFilters,
|
||||
sortKey: request.sortKey,
|
||||
groupMode: request.groupMode,
|
||||
),
|
||||
);
|
||||
},
|
||||
readTaskDetail: (taskId, localDate) {
|
||||
return managementUseCases.getBacklogTaskDetail(
|
||||
GetBacklogTaskDetailRequest(
|
||||
context: context('ui-read-backlog-detail'),
|
||||
localDate: localDate,
|
||||
taskId: taskId,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a generic read controller for Today-state panes.
|
||||
UiReadController<TodayState> createTodayController() {
|
||||
return ApplicationReadController<TodayState>(
|
||||
|
|
@ -176,18 +199,16 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
|||
);
|
||||
}
|
||||
|
||||
/// Creates a generic read controller for backlog panes.
|
||||
UiReadController<BacklogQueryResult> createBacklogController() {
|
||||
return ApplicationReadController<BacklogQueryResult>(
|
||||
/// Creates a generic read controller for the settings screen.
|
||||
@override
|
||||
UiReadController<OwnerSettings> createOwnerSettingsController() {
|
||||
return ApplicationReadController<OwnerSettings>(
|
||||
read: () {
|
||||
return managementUseCases.getBacklog(
|
||||
GetBacklogRequest(
|
||||
context: context('ui-read-backlog'),
|
||||
sortKey: BacklogSortKey.age,
|
||||
),
|
||||
return managementUseCases.getOwnerSettings(
|
||||
GetOwnerSettingsRequest(context: context('ui-read-owner-settings')),
|
||||
);
|
||||
},
|
||||
isEmpty: (state) => state.items.isEmpty,
|
||||
isEmpty: (_) => false,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -199,6 +220,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
|||
}) {
|
||||
return SchedulerCommandController(
|
||||
commands: commandUseCases,
|
||||
management: managementUseCases,
|
||||
contextFor: context,
|
||||
selectedDate: selectedDate,
|
||||
refreshReads: refreshReads,
|
||||
|
|
|
|||
|
|
@ -9,13 +9,18 @@ import 'dart:async';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../controllers/backlog_board_controller.dart';
|
||||
import '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/scheduler_read_controller.dart';
|
||||
import '../controllers/selected_date_controller.dart';
|
||||
import '../controllers/today_screen_controller.dart';
|
||||
import '../models/navigation/focus_flow_section.dart';
|
||||
import '../models/today_screen_models.dart';
|
||||
import '../theme/focus_flow_theme.dart';
|
||||
import '../theme/focus_flow_tokens.dart';
|
||||
import '../widgets/app_shell.dart';
|
||||
import '../widgets/backlog/backlog_board_screen.dart';
|
||||
import '../widgets/dialogs/settings_dialog.dart';
|
||||
import '../widgets/required_banner.dart';
|
||||
import '../widgets/sidebar.dart';
|
||||
import '../widgets/task_selection_modal.dart';
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@ part of '../focus_flow_app.dart';
|
|||
/// Private implementation type for `_FocusFlowHomeState` in this library.
|
||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||
class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||
/// Stores the `controller` value for this object.
|
||||
/// Stores the `todayController` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
late final TodayScreenController controller;
|
||||
late final TodayScreenController todayController;
|
||||
|
||||
/// Stores the `backlogController` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
late final BacklogBoardController backlogController;
|
||||
|
||||
/// Stores the `selectedDateController` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
|
|
@ -18,6 +22,14 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
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.
|
||||
/// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
|
||||
@override
|
||||
|
|
@ -25,15 +37,22 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
super.initState();
|
||||
logger.debug(() => 'FocusFlow home initializing controllers.');
|
||||
selectedDateController = widget.composition.createSelectedDateController();
|
||||
controller = widget.composition.createTodayScreenController(
|
||||
todayController = widget.composition.createTodayScreenController(
|
||||
selectedDates: selectedDateController,
|
||||
);
|
||||
backlogController = widget.composition.createBacklogBoardController(
|
||||
selectedDates: selectedDateController,
|
||||
);
|
||||
ownerSettingsController = widget.composition
|
||||
.createOwnerSettingsController();
|
||||
commandController = widget.composition.createCommandController(
|
||||
refreshReads: controller.load,
|
||||
refreshReads: _refreshReads,
|
||||
selectedDate: () => selectedDateController.date,
|
||||
);
|
||||
logger.finer(() => 'FocusFlow home triggering initial Today load.');
|
||||
controller.load();
|
||||
todayController.load();
|
||||
logger.finer(() => 'FocusFlow home triggering initial settings load.');
|
||||
unawaited(ownerSettingsController.load());
|
||||
}
|
||||
|
||||
/// Releases resources owned by this object before it leaves the tree.
|
||||
|
|
@ -42,7 +61,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
void dispose() {
|
||||
logger.debug(() => 'FocusFlow home disposing controllers.');
|
||||
commandController.dispose();
|
||||
controller.dispose();
|
||||
backlogController.dispose();
|
||||
todayController.dispose();
|
||||
ownerSettingsController.dispose();
|
||||
selectedDateController.dispose();
|
||||
unawaited(widget.composition.dispose());
|
||||
super.dispose();
|
||||
|
|
@ -114,7 +135,78 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
taskId: card.id,
|
||||
expectedUpdatedAt: card.expectedUpdatedAt,
|
||||
);
|
||||
controller.clearSelection();
|
||||
todayController.clearSelection();
|
||||
}
|
||||
|
||||
/// Refreshes every read surface affected by scheduler commands.
|
||||
Future<void> _refreshReads() async {
|
||||
await Future.wait([
|
||||
todayController.load(),
|
||||
backlogController.load(),
|
||||
ownerSettingsController.load(),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Selects the visible app [section].
|
||||
void _selectSection(FocusFlowSection section) {
|
||||
if (section == FocusFlowSection.settings) {
|
||||
unawaited(_openSettings());
|
||||
return;
|
||||
}
|
||||
if (section == _activeSection) {
|
||||
return;
|
||||
}
|
||||
logger.debug(() => 'FocusFlow section selected. section=${section.name}');
|
||||
setState(() {
|
||||
_activeSection = section;
|
||||
});
|
||||
if (section == FocusFlowSection.backlog) {
|
||||
unawaited(backlogController.load());
|
||||
}
|
||||
}
|
||||
|
||||
/// Breaks [card] up into the drafted [children] tasks.
|
||||
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.
|
||||
|
|
@ -156,55 +248,80 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
return Scaffold(
|
||||
backgroundColor: FocusFlowTokens.appBackground,
|
||||
body: AppShell(
|
||||
sidebar: const Sidebar(),
|
||||
child: AnimatedBuilder(
|
||||
animation: controller,
|
||||
sidebar: Sidebar(
|
||||
activeSection: _activeSection,
|
||||
onSectionSelected: _selectSection,
|
||||
),
|
||||
child: switch (_activeSection) {
|
||||
FocusFlowSection.today => _buildTodayScreen(),
|
||||
FocusFlowSection.backlog => BacklogBoardScreen(
|
||||
controller: backlogController,
|
||||
commandController: commandController,
|
||||
),
|
||||
FocusFlowSection.projects ||
|
||||
FocusFlowSection.reports ||
|
||||
FocusFlowSection.settings => _SectionPlaceholder(
|
||||
section: _activeSection,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the current Today screen branch.
|
||||
Widget _buildTodayScreen() {
|
||||
return AnimatedBuilder(
|
||||
animation: todayController,
|
||||
builder: (context, _) {
|
||||
return switch (controller.state) {
|
||||
return switch (todayController.state) {
|
||||
TodayScreenLoading() => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
TodayScreenFailure(:final message) => _PlanIssue(
|
||||
message: message,
|
||||
),
|
||||
TodayScreenFailure(:final message) => _PlanIssue(message: message),
|
||||
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
|
||||
data: data,
|
||||
selectedCard: controller.selectedCard,
|
||||
onSelectCard: controller.selectCard,
|
||||
selectedCard: todayController.selectedCard,
|
||||
onSelectCard: todayController.selectCard,
|
||||
onCompleteCard: _toggleCardCompletion,
|
||||
onPushToNext: _pushCardToNext,
|
||||
onPushToTomorrow: _pushCardToTomorrow,
|
||||
onPushToBacklog: _pushCardToBacklog,
|
||||
onRemoveCard: _removeCard,
|
||||
onAddTask: _addGenericFlexibleTask,
|
||||
onClearSelection: controller.clearSelection,
|
||||
onBreakUpCard: _breakUpCard,
|
||||
onClearSelection: todayController.clearSelection,
|
||||
onPreviousDate: _selectPreviousDate,
|
||||
onNextDate: _selectNextDate,
|
||||
onChooseDate: () {
|
||||
unawaited(_chooseDate());
|
||||
},
|
||||
onOpenSettings: () {
|
||||
unawaited(_openSettings());
|
||||
},
|
||||
),
|
||||
TodayScreenReady(:final data) => _TodayScreenScaffold(
|
||||
data: data,
|
||||
selectedCard: controller.selectedCard,
|
||||
onSelectCard: controller.selectCard,
|
||||
selectedCard: todayController.selectedCard,
|
||||
onSelectCard: todayController.selectCard,
|
||||
onCompleteCard: _toggleCardCompletion,
|
||||
onPushToNext: _pushCardToNext,
|
||||
onPushToTomorrow: _pushCardToTomorrow,
|
||||
onPushToBacklog: _pushCardToBacklog,
|
||||
onRemoveCard: _removeCard,
|
||||
onBreakUpCard: _breakUpCard,
|
||||
onAddTask: _addGenericFlexibleTask,
|
||||
onClearSelection: controller.clearSelection,
|
||||
onClearSelection: todayController.clearSelection,
|
||||
onPreviousDate: _selectPreviousDate,
|
||||
onNextDate: _selectNextDate,
|
||||
onChooseDate: () {
|
||||
unawaited(_chooseDate());
|
||||
},
|
||||
onOpenSettings: () {
|
||||
unawaited(_openSettings());
|
||||
},
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -237,3 +354,48 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
return CivilDate(dateTime.year, dateTime.month, dateTime.day);
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder for navigation sections that are not part of the V1 screen work.
|
||||
class _SectionPlaceholder extends StatelessWidget {
|
||||
/// Creates a placeholder for [section].
|
||||
const _SectionPlaceholder({required this.section});
|
||||
|
||||
/// Section represented by this placeholder.
|
||||
final FocusFlowSection section;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||
child: Text(
|
||||
_labelFor(section),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves display text for [section].
|
||||
String _labelFor(FocusFlowSection section) {
|
||||
return switch (section) {
|
||||
FocusFlowSection.today => 'Today',
|
||||
FocusFlowSection.backlog => 'Backlog',
|
||||
FocusFlowSection.projects => 'Projects',
|
||||
FocusFlowSection.reports => 'Reports',
|
||||
FocusFlowSection.settings => 'Settings',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ class _TodayScreenScaffold extends StatefulWidget {
|
|||
required this.onPushToTomorrow,
|
||||
required this.onPushToBacklog,
|
||||
required this.onRemoveCard,
|
||||
required this.onBreakUpCard,
|
||||
required this.onAddTask,
|
||||
required this.onClearSelection,
|
||||
required this.onPreviousDate,
|
||||
required this.onNextDate,
|
||||
required this.onChooseDate,
|
||||
required this.onOpenSettings,
|
||||
});
|
||||
|
||||
/// Stores the `data` value for this object.
|
||||
|
|
@ -56,6 +58,14 @@ class _TodayScreenScaffold extends StatefulWidget {
|
|||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final Future<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.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final Future<void> Function() onAddTask;
|
||||
|
|
@ -76,6 +86,10 @@ class _TodayScreenScaffold extends StatefulWidget {
|
|||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final VoidCallback onChooseDate;
|
||||
|
||||
/// Stores the `onOpenSettings` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final VoidCallback onOpenSettings;
|
||||
|
||||
/// Creates the mutable state object used by this stateful widget.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
onNextDate: widget.onNextDate,
|
||||
onDatePressed: widget.onChooseDate,
|
||||
onDateLabelPressed: _scrollTimelineToCurrentTime,
|
||||
onOpenSettings: widget.onOpenSettings,
|
||||
),
|
||||
if (widget.data.requiredBanner == null)
|
||||
const SizedBox(height: 16)
|
||||
|
|
@ -103,6 +104,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
onPushToNext: widget.onPushToNext,
|
||||
onPushToTomorrow: widget.onPushToTomorrow,
|
||||
onPushToBacklog: widget.onPushToBacklog,
|
||||
onBreakUpCard: widget.onBreakUpCard,
|
||||
now: () =>
|
||||
DateTime.now().toUtc().add(widget.data.timeZoneOffset),
|
||||
),
|
||||
|
|
@ -134,6 +136,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
onPushToTomorrow: widget.onPushToTomorrow,
|
||||
onPushToBacklog: widget.onPushToBacklog,
|
||||
onRemove: widget.onRemoveCard,
|
||||
onBreakUp: widget.onBreakUpCard,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import 'package:scheduler_core/scheduler_core.dart'
|
|||
show logger;
|
||||
import 'package:scheduler_persistence_sqlite/sqlite.dart';
|
||||
|
||||
import '../controllers/backlog_board_controller.dart';
|
||||
import '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/scheduler_read_controller.dart';
|
||||
import '../controllers/selected_date_controller.dart';
|
||||
import '../controllers/today_screen_controller.dart';
|
||||
import 'runtime/focus_flow_file_logger.dart';
|
||||
|
|
@ -204,6 +206,58 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
);
|
||||
}
|
||||
|
||||
/// Creates the controller used by the Backlog board screen.
|
||||
@override
|
||||
BacklogBoardController createBacklogBoardController({
|
||||
required SelectedDateController selectedDates,
|
||||
}) {
|
||||
scheduler_core.logger.finer(
|
||||
() => 'Creating backlog board controller. date=${date.toIsoString()}',
|
||||
);
|
||||
return BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: dateLabelFor,
|
||||
readBoard: (request) {
|
||||
return managementUseCases.getBacklogBoard(
|
||||
GetBacklogBoardRequest(
|
||||
context: context('ui-read-backlog-board'),
|
||||
localDate: request.localDate,
|
||||
searchText: request.searchText,
|
||||
filters: request.filters,
|
||||
boardFilters: request.boardFilters,
|
||||
sortKey: request.sortKey,
|
||||
groupMode: request.groupMode,
|
||||
),
|
||||
);
|
||||
},
|
||||
readTaskDetail: (taskId, localDate) {
|
||||
return managementUseCases.getBacklogTaskDetail(
|
||||
GetBacklogTaskDetailRequest(
|
||||
context: context('ui-read-backlog-detail'),
|
||||
localDate: localDate,
|
||||
taskId: taskId,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a read controller for the settings screen.
|
||||
@override
|
||||
UiReadController<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.
|
||||
@override
|
||||
SchedulerCommandController createCommandController({
|
||||
|
|
@ -216,6 +270,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
);
|
||||
return SchedulerCommandController(
|
||||
commands: commandUseCases,
|
||||
management: managementUseCases,
|
||||
contextFor: context,
|
||||
selectedDate: selectedDate,
|
||||
refreshReads: refreshReads,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ library;
|
|||
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../controllers/backlog_board_controller.dart';
|
||||
import '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/scheduler_read_controller.dart';
|
||||
import '../controllers/selected_date_controller.dart';
|
||||
import '../controllers/today_screen_controller.dart';
|
||||
|
||||
|
|
@ -32,6 +34,14 @@ abstract interface class SchedulerAppComposition {
|
|||
required SelectedDateController selectedDates,
|
||||
});
|
||||
|
||||
/// Creates the controller used by the Backlog board screen.
|
||||
BacklogBoardController createBacklogBoardController({
|
||||
required SelectedDateController selectedDates,
|
||||
});
|
||||
|
||||
/// Creates a read controller for the settings screen.
|
||||
UiReadController<OwnerSettings> createOwnerSettingsController();
|
||||
|
||||
/// Creates the command controller used by interactive scheduler controls.
|
||||
SchedulerCommandController createCommandController({
|
||||
required ReadRefresh refreshReads,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,552 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Coordinates Backlog board controller state for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../models/backlog/backlog_board_screen_data.dart';
|
||||
import 'selected_date_controller.dart';
|
||||
|
||||
/// Reads Backlog board state from the scheduler application layer.
|
||||
typedef BacklogBoardReader =
|
||||
Future<ApplicationResult<BacklogBoardQueryResult>> Function(
|
||||
BacklogBoardReadRequest request,
|
||||
);
|
||||
|
||||
/// Reads one Backlog task detail model from the scheduler application layer.
|
||||
typedef BacklogTaskDetailReader =
|
||||
Future<ApplicationResult<BacklogTaskDetailReadModel>> Function(
|
||||
String taskId,
|
||||
CivilDate localDate,
|
||||
);
|
||||
|
||||
/// Formats a selected date for Backlog loading and empty states.
|
||||
typedef BacklogDateLabelFormatter = String Function(CivilDate date);
|
||||
|
||||
/// Immutable controller request for a Backlog board read.
|
||||
class BacklogBoardReadRequest {
|
||||
/// Creates a Backlog board read request from UI filter state.
|
||||
BacklogBoardReadRequest({
|
||||
required this.localDate,
|
||||
this.searchText,
|
||||
List<BacklogFilter> filters = const <BacklogFilter>[],
|
||||
this.boardFilters = const BacklogBoardFilterSelection.empty(),
|
||||
this.sortKey = BacklogSortKey.custom,
|
||||
this.groupMode = BacklogBoardGroupMode.none,
|
||||
}) : filters = List<BacklogFilter>.unmodifiable(filters);
|
||||
|
||||
/// Owner-local date used for slot preview context.
|
||||
final CivilDate localDate;
|
||||
|
||||
/// Optional search text entered by the user.
|
||||
final String? searchText;
|
||||
|
||||
/// User-selected backlog filters.
|
||||
final List<BacklogFilter> filters;
|
||||
|
||||
/// User-selected board-specific filters.
|
||||
final BacklogBoardFilterSelection boardFilters;
|
||||
|
||||
/// User-selected task ordering.
|
||||
final BacklogSortKey sortKey;
|
||||
|
||||
/// User-selected grouping mode.
|
||||
final BacklogBoardGroupMode groupMode;
|
||||
}
|
||||
|
||||
/// Base state for the Backlog board screen.
|
||||
sealed class BacklogBoardScreenState {
|
||||
/// Creates a Backlog board screen state.
|
||||
const BacklogBoardScreenState();
|
||||
}
|
||||
|
||||
/// Indicates that the Backlog board is loading.
|
||||
class BacklogBoardLoading extends BacklogBoardScreenState {
|
||||
/// Creates a loading Backlog state.
|
||||
const BacklogBoardLoading(this.dateLabel);
|
||||
|
||||
/// Display-ready date label for the read in progress.
|
||||
final String dateLabel;
|
||||
}
|
||||
|
||||
/// Indicates that the Backlog board has renderable data.
|
||||
class BacklogBoardReady extends BacklogBoardScreenState {
|
||||
/// Creates a ready Backlog state.
|
||||
const BacklogBoardReady(this.data);
|
||||
|
||||
/// Presentation data for the Backlog board.
|
||||
final BacklogBoardScreenData data;
|
||||
}
|
||||
|
||||
/// Indicates that the Backlog board loaded with no tasks.
|
||||
class BacklogBoardEmpty extends BacklogBoardScreenState {
|
||||
/// Creates an empty Backlog state.
|
||||
const BacklogBoardEmpty(this.data);
|
||||
|
||||
/// Presentation data for the empty Backlog board.
|
||||
final BacklogBoardScreenData data;
|
||||
}
|
||||
|
||||
/// Indicates that the Backlog board failed to load.
|
||||
class BacklogBoardFailure extends BacklogBoardScreenState {
|
||||
/// Creates a failed Backlog state.
|
||||
const BacklogBoardFailure({required this.message, required this.dateLabel});
|
||||
|
||||
/// User-facing failure message or code.
|
||||
final String message;
|
||||
|
||||
/// Display-ready date label for the failed read.
|
||||
final String dateLabel;
|
||||
}
|
||||
|
||||
/// Loads Backlog board data and tracks UI-local selection/filter state.
|
||||
class BacklogBoardController extends ChangeNotifier {
|
||||
/// Creates a Backlog board controller from read callbacks.
|
||||
BacklogBoardController({
|
||||
required this.readBoard,
|
||||
required this.readTaskDetail,
|
||||
required this.selectedDates,
|
||||
required this.dateLabelFor,
|
||||
}) : _state = BacklogBoardLoading(dateLabelFor(selectedDates.date)) {
|
||||
selectedDates.addListener(_handleSelectedDateChanged);
|
||||
}
|
||||
|
||||
/// Callback used to read board state.
|
||||
final BacklogBoardReader readBoard;
|
||||
|
||||
/// Callback used to read selected task detail state.
|
||||
final BacklogTaskDetailReader readTaskDetail;
|
||||
|
||||
/// Selected planning date used for board and suggestion reads.
|
||||
final SelectedDateController selectedDates;
|
||||
|
||||
/// Formats dates before a backend read has returned board data.
|
||||
final BacklogDateLabelFormatter dateLabelFor;
|
||||
|
||||
/// Private state stored as `_state` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
BacklogBoardScreenState _state;
|
||||
|
||||
/// Private state stored as `_selectedTaskId` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
String? _selectedTaskId;
|
||||
|
||||
/// Private state stored as `_selectedTaskDetail` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
BacklogTaskDetailReadModel? _selectedTaskDetail;
|
||||
|
||||
/// Private state stored as `_selectedTaskDetailError` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
String? _selectedTaskDetailError;
|
||||
|
||||
/// Private state stored as `_selectedTaskActionMessage` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
String? _selectedTaskActionMessage;
|
||||
|
||||
/// Private state stored as `_searchText` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
String? _searchText;
|
||||
|
||||
/// Private state stored as `_filters` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
List<BacklogFilter> _filters = const <BacklogFilter>[];
|
||||
|
||||
/// Private state stored as `_boardFilters` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
BacklogBoardFilterSelection _boardFilters =
|
||||
const BacklogBoardFilterSelection.empty();
|
||||
|
||||
/// Private state stored as `_sortKey` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
BacklogSortKey _sortKey = BacklogSortKey.custom;
|
||||
|
||||
/// Private state stored as `_groupMode` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
BacklogBoardGroupMode _groupMode = BacklogBoardGroupMode.none;
|
||||
|
||||
/// Private state stored as `_loadSequence` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
var _loadSequence = 0;
|
||||
|
||||
/// Private state stored as `_detailSequence` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
var _detailSequence = 0;
|
||||
|
||||
/// Private state stored as `_isDisposed` for this implementation.
|
||||
/// The field is intentionally scoped to this library so only the owning type can change the related scheduler or UI behavior.
|
||||
var _isDisposed = false;
|
||||
|
||||
/// Current Backlog board screen state.
|
||||
BacklogBoardScreenState get state => _state;
|
||||
|
||||
/// Currently selected Backlog task id, if any.
|
||||
String? get selectedTaskId => _selectedTaskId;
|
||||
|
||||
/// Detail model for the currently selected Backlog task, if it has loaded.
|
||||
BacklogTaskDetailReadModel? get selectedTaskDetail => _selectedTaskDetail;
|
||||
|
||||
/// Drawer-local error message for the selected task detail read.
|
||||
String? get selectedTaskDetailError => _selectedTaskDetailError;
|
||||
|
||||
/// Drawer-local action message for the selected task.
|
||||
String? get selectedTaskActionMessage => _selectedTaskActionMessage;
|
||||
|
||||
/// Whether a selected task detail read is currently unresolved.
|
||||
bool get isSelectedTaskDetailLoading =>
|
||||
_selectedTaskId != null &&
|
||||
_selectedTaskDetail == null &&
|
||||
_selectedTaskDetailError == null;
|
||||
|
||||
/// Current search text applied to board reads.
|
||||
String? get searchText => _searchText;
|
||||
|
||||
/// Current filters applied to board reads.
|
||||
List<BacklogFilter> get filters => _filters;
|
||||
|
||||
/// Current board-specific filters applied to board reads.
|
||||
BacklogBoardFilterSelection get boardFilters => _boardFilters;
|
||||
|
||||
/// Current sort key applied to board reads.
|
||||
BacklogSortKey get sortKey => _sortKey;
|
||||
|
||||
/// Current group mode applied to board reads.
|
||||
BacklogBoardGroupMode get groupMode => _groupMode;
|
||||
|
||||
/// Loads the current Backlog board state.
|
||||
Future<void> load() async {
|
||||
final date = selectedDates.date;
|
||||
final sequence = _nextLoadSequence();
|
||||
logger.debug(
|
||||
() =>
|
||||
'Backlog board load started. date=${date.toIsoString()} sequence=$sequence',
|
||||
);
|
||||
_state = BacklogBoardLoading(dateLabelFor(date));
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await readBoard(
|
||||
BacklogBoardReadRequest(
|
||||
localDate: date,
|
||||
searchText: _searchText,
|
||||
filters: _filters,
|
||||
boardFilters: _boardFilters,
|
||||
sortKey: _sortKey,
|
||||
groupMode: _groupMode,
|
||||
),
|
||||
);
|
||||
if (_isStale(sequence)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Backlog board load result. '
|
||||
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final failure = result.failure;
|
||||
if (failure != null) {
|
||||
logger.warn(
|
||||
() =>
|
||||
'Backlog board load returned failure. '
|
||||
'date=${date.toIsoString()} code=${failure.code.name} '
|
||||
'detailCode=${failure.detailCode} entityId=${failure.entityId}',
|
||||
);
|
||||
_state = BacklogBoardFailure(
|
||||
message: failure.detailCode ?? failure.code.name,
|
||||
dateLabel: dateLabelFor(date),
|
||||
);
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final board = result.requireValue;
|
||||
_syncSelectedTask(board);
|
||||
final data = BacklogBoardScreenData(
|
||||
board: board,
|
||||
selectedTaskDetail: _selectedTaskDetail,
|
||||
);
|
||||
_state = board.summary.totalTaskCount == 0
|
||||
? BacklogBoardEmpty(data)
|
||||
: BacklogBoardReady(data);
|
||||
logger.debug(
|
||||
() =>
|
||||
'Backlog board load finished. date=${date.toIsoString()} '
|
||||
'sequence=$sequence taskCount=${board.summary.totalTaskCount}',
|
||||
);
|
||||
notifyListeners();
|
||||
} on Object catch (error, stackTrace) {
|
||||
if (_isStale(sequence)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Backlog board load exception. '
|
||||
'date=${date.toIsoString()} sequence=$sequence currentSequence=$_loadSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.error(
|
||||
() =>
|
||||
'Backlog board load threw unexpectedly. date=${date.toIsoString()} sequence=$sequence',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_state = BacklogBoardFailure(
|
||||
message: error.toString(),
|
||||
dateLabel: dateLabelFor(date),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects [taskId] and requests drawer detail data.
|
||||
Future<void> selectTask(String taskId) async {
|
||||
if (_selectedTaskId == taskId && _selectedTaskDetail != null) {
|
||||
return;
|
||||
}
|
||||
_selectedTaskId = taskId;
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = null;
|
||||
_selectedTaskActionMessage = null;
|
||||
notifyListeners();
|
||||
await _loadSelectedTaskDetail(taskId);
|
||||
}
|
||||
|
||||
/// Reloads the currently selected task detail, if a task is selected.
|
||||
Future<void> refreshSelectedTaskDetail() async {
|
||||
final taskId = _selectedTaskId;
|
||||
if (taskId == null) {
|
||||
return;
|
||||
}
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = null;
|
||||
_selectedTaskActionMessage = null;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
await _loadSelectedTaskDetail(taskId);
|
||||
}
|
||||
|
||||
/// Shows [message] in the selected task drawer without mutating scheduler data.
|
||||
void showSelectedTaskActionMessage(String message) {
|
||||
if (_selectedTaskId == null) {
|
||||
return;
|
||||
}
|
||||
_selectedTaskActionMessage = message;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clears UI-local task selection.
|
||||
void clearSelection() {
|
||||
if (_selectedTaskId == null && _selectedTaskDetail == null) {
|
||||
return;
|
||||
}
|
||||
_selectedTaskId = null;
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = null;
|
||||
_selectedTaskActionMessage = null;
|
||||
_detailSequence += 1;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Updates search text and reloads the board.
|
||||
Future<void> updateSearchText(String? value) async {
|
||||
final trimmed = value?.trim();
|
||||
final next = trimmed == null || trimmed.isEmpty ? null : trimmed;
|
||||
if (next == _searchText) {
|
||||
return;
|
||||
}
|
||||
_searchText = next;
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Updates selected filters and reloads the board.
|
||||
Future<void> updateFilters(List<BacklogFilter> value) async {
|
||||
_filters = List<BacklogFilter>.unmodifiable(value);
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Updates legacy and board-specific filters together and reloads once.
|
||||
Future<void> updateFilterState({
|
||||
required List<BacklogFilter> filters,
|
||||
required BacklogBoardFilterSelection boardFilters,
|
||||
}) async {
|
||||
_filters = List<BacklogFilter>.unmodifiable(filters);
|
||||
_boardFilters = boardFilters;
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Updates board-specific filters and reloads the board.
|
||||
Future<void> updateBoardFilters(BacklogBoardFilterSelection value) async {
|
||||
_boardFilters = value;
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Updates sort key and reloads the board.
|
||||
Future<void> updateSortKey(BacklogSortKey value) async {
|
||||
if (value == _sortKey) {
|
||||
return;
|
||||
}
|
||||
_sortKey = value;
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Updates group mode and reloads the board.
|
||||
Future<void> updateGroupMode(BacklogBoardGroupMode value) async {
|
||||
if (value == _groupMode) {
|
||||
return;
|
||||
}
|
||||
_groupMode = value;
|
||||
await load();
|
||||
}
|
||||
|
||||
/// Retries the latest board read.
|
||||
Future<void> retry() => load();
|
||||
|
||||
/// Releases selected-date listeners owned by this controller.
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
selectedDates.removeListener(_handleSelectedDateChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Loads detail data for the already-selected [taskId].
|
||||
Future<void> _loadSelectedTaskDetail(String taskId) async {
|
||||
final sequence = _nextDetailSequence();
|
||||
final date = selectedDates.date;
|
||||
logger.finer(
|
||||
() =>
|
||||
'Backlog detail load started. taskId=$taskId date=${date.toIsoString()} sequence=$sequence',
|
||||
);
|
||||
try {
|
||||
final result = await readTaskDetail(taskId, date);
|
||||
if (_isDetailStale(sequence, taskId)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Backlog detail load result. '
|
||||
'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final failure = result.failure;
|
||||
if (failure != null) {
|
||||
logger.warn(
|
||||
() =>
|
||||
'Backlog detail load returned failure. taskId=$taskId '
|
||||
'code=${failure.code.name} detailCode=${failure.detailCode}',
|
||||
);
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = failure.detailCode ?? failure.code.name;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_selectedTaskDetail = result.requireValue;
|
||||
_selectedTaskDetailError = null;
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
} on Object catch (error, stackTrace) {
|
||||
if (_isDetailStale(sequence, taskId)) {
|
||||
logger.finer(
|
||||
() =>
|
||||
'Ignoring stale Backlog detail load exception. '
|
||||
'taskId=$taskId sequence=$sequence currentSequence=$_detailSequence',
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.error(
|
||||
() =>
|
||||
'Backlog detail load threw unexpectedly. taskId=$taskId sequence=$sequence',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = error.toString();
|
||||
_refreshDataState();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the `_handleSelectedDateChanged` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
void _handleSelectedDateChanged() {
|
||||
logger.debug(
|
||||
() =>
|
||||
'Backlog board selected date changed. date=${selectedDates.date.toIsoString()}',
|
||||
);
|
||||
clearSelection();
|
||||
unawaited(load());
|
||||
}
|
||||
|
||||
/// Runs the `_syncSelectedTask` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
void _syncSelectedTask(BacklogBoardQueryResult board) {
|
||||
final selectedId = _selectedTaskId;
|
||||
if (selectedId == null) {
|
||||
return;
|
||||
}
|
||||
final stillVisible = board.columns
|
||||
.expand((column) => column.items)
|
||||
.any((item) => item.taskId == selectedId);
|
||||
if (stillVisible) {
|
||||
return;
|
||||
}
|
||||
_selectedTaskId = null;
|
||||
_selectedTaskDetail = null;
|
||||
_selectedTaskDetailError = null;
|
||||
_selectedTaskActionMessage = null;
|
||||
}
|
||||
|
||||
/// Runs the `_refreshDataState` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
void _refreshDataState() {
|
||||
switch (_state) {
|
||||
case BacklogBoardReady(:final data):
|
||||
_state = BacklogBoardReady(
|
||||
BacklogBoardScreenData(
|
||||
board: data.board,
|
||||
selectedTaskDetail: _selectedTaskDetail,
|
||||
),
|
||||
);
|
||||
case BacklogBoardEmpty(:final data):
|
||||
_state = BacklogBoardEmpty(
|
||||
BacklogBoardScreenData(
|
||||
board: data.board,
|
||||
selectedTaskDetail: _selectedTaskDetail,
|
||||
),
|
||||
);
|
||||
case BacklogBoardLoading() || BacklogBoardFailure():
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the `_nextLoadSequence` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
int _nextLoadSequence() {
|
||||
_loadSequence += 1;
|
||||
return _loadSequence;
|
||||
}
|
||||
|
||||
/// Runs the `_nextDetailSequence` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
int _nextDetailSequence() {
|
||||
_detailSequence += 1;
|
||||
return _detailSequence;
|
||||
}
|
||||
|
||||
/// Runs the `_isStale` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
bool _isStale(int sequence) {
|
||||
return _isDisposed || sequence != _loadSequence;
|
||||
}
|
||||
|
||||
/// Runs the `_isDetailStale` helper used inside this library.
|
||||
/// The helper centralizes a small piece of transformation or validation so higher-level scheduler flow remains easier to read.
|
||||
bool _isDetailStale(int sequence, String taskId) {
|
||||
return _isDisposed ||
|
||||
sequence != _detailSequence ||
|
||||
taskId != _selectedTaskId;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
/// Creates a command controller from scheduler use cases and UI callbacks.
|
||||
SchedulerCommandController({
|
||||
required this.commands,
|
||||
required this.management,
|
||||
required this.contextFor,
|
||||
required this.selectedDate,
|
||||
required this.refreshReads,
|
||||
|
|
@ -21,6 +22,10 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
/// Scheduler write use cases invoked by this controller.
|
||||
final V1ApplicationCommandUseCases commands;
|
||||
|
||||
/// Non-scheduling management use cases (owner settings, projects, locked
|
||||
/// time) invoked by this controller.
|
||||
final V1ApplicationManagementUseCases management;
|
||||
|
||||
/// Factory for command-scoped application operation contexts.
|
||||
final OperationContextFactory contextFor;
|
||||
|
||||
|
|
@ -134,6 +139,116 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
);
|
||||
}
|
||||
|
||||
/// Schedules a backlog task into a specific suggested slot.
|
||||
Future<void> scheduleBacklogItemAtSuggestedSlot({
|
||||
required String taskId,
|
||||
required DateTime scheduledStartUtc,
|
||||
required DateTime scheduledEndUtc,
|
||||
required DateTime expectedUpdatedAt,
|
||||
}) async {
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: schedule backlog item at suggested slot. '
|
||||
'taskId=$taskId scheduledStart=${scheduledStartUtc.toIso8601String()} '
|
||||
'scheduledEnd=${scheduledEndUtc.toIso8601String()} '
|
||||
'expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}',
|
||||
);
|
||||
await _run(
|
||||
label: 'Scheduling task',
|
||||
successMessage: 'Task scheduled',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
final commandAt = now();
|
||||
return commands.scheduleBacklogItemAtSuggestedSlot(
|
||||
context: contextFor(operationId, now: commandAt),
|
||||
localDate: selectedDate(),
|
||||
taskId: taskId,
|
||||
scheduledStart: scheduledStartUtc,
|
||||
scheduledEnd: scheduledEndUtc,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Breaks a Backlog task into direct child tasks.
|
||||
Future<void> breakUpBacklogTask({
|
||||
required String parentTaskId,
|
||||
required List<ApplicationChildTaskDraft> children,
|
||||
required DateTime expectedUpdatedAt,
|
||||
}) async {
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: break up backlog task. '
|
||||
'parentTaskId=$parentTaskId childCount=${children.length} '
|
||||
'expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}',
|
||||
);
|
||||
await _run(
|
||||
label: 'Breaking up task',
|
||||
successMessage: 'Task broken up',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
final commandAt = now();
|
||||
return commands.breakUpTask(
|
||||
context: contextFor(operationId, now: commandAt),
|
||||
parentTaskId: parentTaskId,
|
||||
children: children,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Moves a Backlog task to the Someday/Not Now board area.
|
||||
Future<void> pushBacklogTaskToSomeday({
|
||||
required String taskId,
|
||||
required DateTime expectedUpdatedAt,
|
||||
}) async {
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: push backlog task to someday. '
|
||||
'taskId=$taskId expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}',
|
||||
);
|
||||
await _run(
|
||||
label: 'Moving task to Someday',
|
||||
successMessage: 'Moved to Someday',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
final commandAt = now();
|
||||
return commands.pushBacklogTaskToSomeday(
|
||||
context: contextFor(operationId, now: commandAt),
|
||||
taskId: taskId,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes a task from the active Backlog without deleting its record.
|
||||
Future<void> archiveBacklogTask({
|
||||
required String taskId,
|
||||
required DateTime expectedUpdatedAt,
|
||||
}) async {
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: archive backlog task. '
|
||||
'taskId=$taskId expectedUpdatedAt=${expectedUpdatedAt.toIso8601String()}',
|
||||
);
|
||||
await _run(
|
||||
label: 'Removing from Backlog',
|
||||
successMessage: 'Removed from active Backlog.',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
final commandAt = now();
|
||||
return commands.archiveBacklogTask(
|
||||
context: contextFor(operationId, now: commandAt),
|
||||
taskId: taskId,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Completes a planned flexible task from the Today view.
|
||||
Future<void> completeFlexibleTask({
|
||||
required String taskId,
|
||||
|
|
@ -184,7 +299,7 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
TaskType.locked ||
|
||||
TaskType.surprise ||
|
||||
TaskType.freeSlot => Future.value(
|
||||
ApplicationResult.failure(
|
||||
ApplicationResult<ApplicationCommandResult>.failure(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
entityId: taskId,
|
||||
|
|
@ -304,6 +419,59 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
);
|
||||
}
|
||||
|
||||
/// Breaks a parent task into direct child tasks.
|
||||
Future<void> breakUpTask({
|
||||
required String parentTaskId,
|
||||
required List<ApplicationChildTaskDraft> children,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) async {
|
||||
final commandAt = now();
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: break up task. '
|
||||
'parentTaskId=$parentTaskId childCount=${children.length} '
|
||||
'hasExpectedUpdatedAt=${expectedUpdatedAt != null}',
|
||||
);
|
||||
await _run(
|
||||
label: 'Breaking up task',
|
||||
successMessage: 'Task broken up',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
return commands.breakUpTask(
|
||||
context: contextFor(operationId, now: commandAt),
|
||||
parentTaskId: parentTaskId,
|
||||
children: children,
|
||||
expectedUpdatedAt: expectedUpdatedAt,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Applies a patch to the owner's persisted settings.
|
||||
Future<void> updateOwnerSettings(OwnerSettingsUpdate update) async {
|
||||
final commandAt = now();
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: update owner settings. '
|
||||
'hasTimeZone=${update.timeZoneId != null} '
|
||||
'hasDayStart=${update.dayStart != null} '
|
||||
'hasDayEnd=${update.dayEnd != null} '
|
||||
'hasCompactMode=${update.compactModeEnabled != null} '
|
||||
'hasStaleness=${update.backlogStalenessSettings != null}',
|
||||
);
|
||||
await _run<OwnerSettingsUpdateResult>(
|
||||
label: 'Saving settings',
|
||||
successMessage: 'Settings saved',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
return management.updateOwnerSettings(
|
||||
context: contextFor(operationId, now: commandAt),
|
||||
update: update,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Permanently removes a task from persistence.
|
||||
Future<void> removeTask({
|
||||
required String taskId,
|
||||
|
|
@ -332,14 +500,11 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
|
||||
/// Runs the `_run` operation and completes after its asynchronous work finishes.
|
||||
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
|
||||
Future<void> _run({
|
||||
Future<void> _run<R>({
|
||||
required String label,
|
||||
required String successMessage,
|
||||
required bool refreshAfterSuccess,
|
||||
required Future<ApplicationResult<ApplicationCommandResult>> Function(
|
||||
String operationId,
|
||||
)
|
||||
action,
|
||||
required Future<ApplicationResult<R>> Function(String operationId) action,
|
||||
}) async {
|
||||
final operationId = _nextOperationId();
|
||||
logger.debug(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Defines Backlog board screen presentation data.
|
||||
library;
|
||||
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
/// Presentation data consumed by the Backlog board screen shell.
|
||||
class BacklogBoardScreenData {
|
||||
/// Creates Backlog screen data from a board query result and optional detail.
|
||||
const BacklogBoardScreenData({required this.board, this.selectedTaskDetail});
|
||||
|
||||
/// Public scheduler read model for the current Backlog board.
|
||||
final BacklogBoardQueryResult board;
|
||||
|
||||
/// Public scheduler read model for the selected task drawer, if loaded.
|
||||
final BacklogTaskDetailReadModel? selectedTaskDetail;
|
||||
|
||||
/// Stable board columns in display order.
|
||||
List<BacklogBoardColumnReadModel> get columns => board.columns;
|
||||
|
||||
/// Summary read model for the current board filters.
|
||||
BacklogBoardSummaryReadModel get summary => board.summary;
|
||||
|
||||
/// Selected task id if a drawer should be considered open.
|
||||
String? get selectedTaskId => selectedTaskDetail?.item.taskId;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Defines primary FocusFlow app sections.
|
||||
library;
|
||||
|
||||
/// Top-level sections selectable from the FocusFlow sidebar.
|
||||
enum FocusFlowSection {
|
||||
/// Today timeline and compact planning surface.
|
||||
today,
|
||||
|
||||
/// Backlog board and backlog task detail surface.
|
||||
backlog,
|
||||
|
||||
/// Project setup and project defaults surface.
|
||||
projects,
|
||||
|
||||
/// Reports and review surface.
|
||||
reports,
|
||||
|
||||
/// Settings surface.
|
||||
settings,
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Defines Backlog board visual tokens for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import 'focus_flow_tokens.dart';
|
||||
|
||||
/// Maps Backlog board read-model tokens to Flutter colors and icons.
|
||||
abstract final class BacklogBoardVisualTokens {
|
||||
/// Accent color for the Do Next bucket.
|
||||
static const doNextAccent = Color(0xFF24E65A);
|
||||
|
||||
/// Accent color for the Need Time Block bucket.
|
||||
static const needTimeBlockAccent = Color(0xFFFFA51F);
|
||||
|
||||
/// Accent color for the Break Up First bucket.
|
||||
static const breakUpFirstAccent = Color(0xFFB45CFF);
|
||||
|
||||
/// Accent color for the Not Now bucket.
|
||||
static const notNowAccent = Color(0xFF35BFFF);
|
||||
|
||||
/// Red flag color for high-priority Backlog cards.
|
||||
static const priorityHigh = Color(0xFFFF3F5B);
|
||||
|
||||
/// Yellow flag color for medium-priority Backlog cards.
|
||||
static const priorityMedium = Color(0xFFFFD447);
|
||||
|
||||
/// Green flag color for low-priority Backlog cards.
|
||||
static const priorityLow = Color(0xFF4BE064);
|
||||
|
||||
/// Project dot color for Home.
|
||||
static const projectHome = Color(0xFF4D86FF);
|
||||
|
||||
/// Project dot color for Work.
|
||||
static const projectWork = Color(0xFFFF7A1A);
|
||||
|
||||
/// Project dot color for Personal.
|
||||
static const projectPersonal = Color(0xFFB45CFF);
|
||||
|
||||
/// Project dot color for Learning.
|
||||
static const projectLearning = Color(0xFF38BDF8);
|
||||
|
||||
/// Project dot color for Side Project.
|
||||
static const projectSideProject = Color(0xFF2F6BFF);
|
||||
|
||||
/// Resolves a Backlog column accent token to a display color.
|
||||
static Color accentTokenColor(
|
||||
String token, {
|
||||
BacklogBoardBucket? fallbackBucket,
|
||||
}) {
|
||||
return switch (token) {
|
||||
'backlog-do-next' => doNextAccent,
|
||||
'backlog-time-block' => needTimeBlockAccent,
|
||||
'backlog-break-up' => breakUpFirstAccent,
|
||||
'backlog-not-now' => notNowAccent,
|
||||
'green' => doNextAccent,
|
||||
'yellow' => needTimeBlockAccent,
|
||||
'purple' => breakUpFirstAccent,
|
||||
'blue' => notNowAccent,
|
||||
_ =>
|
||||
fallbackBucket == null
|
||||
? FocusFlowTokens.textMuted
|
||||
: bucketAccent(fallbackBucket),
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a board bucket to its accent color.
|
||||
static Color bucketAccent(BacklogBoardBucket bucket) {
|
||||
return switch (bucket) {
|
||||
BacklogBoardBucket.doNext => doNextAccent,
|
||||
BacklogBoardBucket.needTimeBlock => needTimeBlockAccent,
|
||||
BacklogBoardBucket.breakUpFirst => breakUpFirstAccent,
|
||||
BacklogBoardBucket.notNow => notNowAccent,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a board bucket to its header icon.
|
||||
static IconData bucketIcon(BacklogBoardBucket bucket) {
|
||||
return switch (bucket) {
|
||||
BacklogBoardBucket.doNext => Icons.check_circle_outline,
|
||||
BacklogBoardBucket.needTimeBlock => Icons.schedule,
|
||||
BacklogBoardBucket.breakUpFirst => Icons.adjust,
|
||||
BacklogBoardBucket.notNow => Icons.cloud_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a task priority to a flag color.
|
||||
static Color priorityColor(PriorityLevel? priority) {
|
||||
return switch (priority) {
|
||||
PriorityLevel.veryHigh || PriorityLevel.high => priorityHigh,
|
||||
PriorityLevel.medium => priorityMedium,
|
||||
PriorityLevel.low || PriorityLevel.veryLow => priorityLow,
|
||||
null => FocusFlowTokens.textFaint,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a priority distribution color token to a flag color.
|
||||
static Color priorityColorToken(String? token) {
|
||||
return switch (token) {
|
||||
'priority-veryHigh' || 'priority-high' => priorityHigh,
|
||||
'priority-medium' => priorityMedium,
|
||||
'priority-low' || 'priority-veryLow' => priorityLow,
|
||||
_ => FocusFlowTokens.textFaint,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a task priority to a compact card label.
|
||||
static String priorityLabel(PriorityLevel? priority) {
|
||||
return switch (priority) {
|
||||
PriorityLevel.veryHigh || PriorityLevel.high => 'High',
|
||||
PriorityLevel.medium => 'Med',
|
||||
PriorityLevel.low || PriorityLevel.veryLow => 'Low',
|
||||
null => 'None',
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a project color token to the Backlog board dot color.
|
||||
static Color projectColor(String token) {
|
||||
return switch (token) {
|
||||
'project-home' || 'home' => projectHome,
|
||||
'project-work' || 'work' => projectWork,
|
||||
'project-personal' || 'personal' => projectPersonal,
|
||||
'project-learning' || 'learning' => projectLearning,
|
||||
'project-side-project' || 'side-project' => projectSideProject,
|
||||
_ => FocusFlowTokens.textMuted,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a duration distribution id to a chart segment color.
|
||||
static Color durationColor(String id) {
|
||||
return switch (id) {
|
||||
'0-30' => priorityLow,
|
||||
'30-60' => notNowAccent,
|
||||
'60-120' => breakUpFirstAccent,
|
||||
'120+' => FocusFlowTokens.accentMagenta,
|
||||
_ => FocusFlowTokens.textFaint,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a reward level to the number of solid reward marks to render.
|
||||
static int rewardCount(RewardLevel reward) {
|
||||
return switch (reward) {
|
||||
RewardLevel.notSet => 0,
|
||||
RewardLevel.veryLow => 1,
|
||||
RewardLevel.low => 2,
|
||||
RewardLevel.medium => 3,
|
||||
RewardLevel.high => 4,
|
||||
RewardLevel.veryHigh => 5,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a Backlog tag chip background color for drawer surfaces.
|
||||
static Color tagChipBackground(String label) {
|
||||
return switch (label.toLowerCase()) {
|
||||
'someday' => notNowAccent.withValues(alpha: 0.16),
|
||||
_ => FocusFlowTokens.elevatedPanel,
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves a Backlog tag chip foreground color for drawer surfaces.
|
||||
static Color tagChipForeground(String label) {
|
||||
return switch (label.toLowerCase()) {
|
||||
'someday' => notNowAccent,
|
||||
_ => FocusFlowTokens.textMuted,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Renders Backlog board columns for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../../theme/backlog_board_visual_tokens.dart';
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
import 'backlog_task_card.dart';
|
||||
|
||||
/// Callback invoked when a Backlog task should become the selected task.
|
||||
typedef BacklogTaskSelected = void Function(String taskId);
|
||||
|
||||
/// Callback invoked when the user requests a new task for a bucket.
|
||||
typedef BacklogBucketAddRequested = void Function(BacklogBoardBucket bucket);
|
||||
|
||||
/// Board column containing a Backlog bucket header, task cards, and footer.
|
||||
class BacklogBoardColumn extends StatelessWidget {
|
||||
/// Creates a Backlog board column.
|
||||
const BacklogBoardColumn({
|
||||
required this.column,
|
||||
required this.selectedTaskId,
|
||||
required this.onTaskSelected,
|
||||
required this.onAddTask,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Column read model supplied by scheduler core.
|
||||
final BacklogBoardColumnReadModel column;
|
||||
|
||||
/// Currently selected task id, if any.
|
||||
final String? selectedTaskId;
|
||||
|
||||
/// Selection callback for task cards.
|
||||
final BacklogTaskSelected onTaskSelected;
|
||||
|
||||
/// Add-task callback shared by header and footer controls.
|
||||
final BacklogBucketAddRequested onAddTask;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = BacklogBoardVisualTokens.accentTokenColor(
|
||||
column.accentToken,
|
||||
fallbackBucket: column.bucket,
|
||||
);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.64)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: accent.withValues(alpha: 0.06),
|
||||
blurRadius: 28,
|
||||
spreadRadius: -10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
BacklogColumnHeader(
|
||||
column: column,
|
||||
accent: accent,
|
||||
onAddTask: () {
|
||||
onAddTask(column.bucket);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: column.items.isEmpty
|
||||
? _EmptyColumnHint(accent: accent)
|
||||
: ListView.separated(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: column.items.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final item = column.items[index];
|
||||
return BacklogTaskCard(
|
||||
item: item,
|
||||
selected: item.taskId == selectedTaskId,
|
||||
onTap: () {
|
||||
onTaskSelected(item.taskId);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
onAddTask(column.bucket);
|
||||
},
|
||||
icon: Icon(Icons.add, color: accent, size: 18),
|
||||
label: Text('Add task', style: TextStyle(color: accent)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Header for a Backlog board column.
|
||||
class BacklogColumnHeader extends StatelessWidget {
|
||||
/// Creates a Backlog column header.
|
||||
const BacklogColumnHeader({
|
||||
required this.column,
|
||||
required this.accent,
|
||||
required this.onAddTask,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Column read model that supplies title, subtitle, bucket, and count.
|
||||
final BacklogBoardColumnReadModel column;
|
||||
|
||||
/// Accent color resolved from the column token.
|
||||
final Color accent;
|
||||
|
||||
/// Callback invoked by the header add button.
|
||||
final VoidCallback onAddTask;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
BacklogBoardVisualTokens.bucketIcon(column.bucket),
|
||||
color: accent,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Text(
|
||||
column.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ColumnCountPill(label: '${column.count}', color: accent),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: onAddTask,
|
||||
icon: const Icon(Icons.add),
|
||||
color: FocusFlowTokens.textMuted,
|
||||
tooltip: 'Add task',
|
||||
iconSize: 18,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32),
|
||||
child: Text(
|
||||
column.subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 11,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small count pill used by Backlog column headers.
|
||||
class _ColumnCountPill extends StatelessWidget {
|
||||
/// Creates a column count pill.
|
||||
const _ColumnCountPill({required this.label, required this.color});
|
||||
|
||||
/// Count label.
|
||||
final String label;
|
||||
|
||||
/// Accent color for the pill.
|
||||
final Color color;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder content for an empty board column.
|
||||
class _EmptyColumnHint extends StatelessWidget {
|
||||
/// Creates an empty-column hint.
|
||||
const _EmptyColumnHint({required this.accent});
|
||||
|
||||
/// Accent color for the empty state icon.
|
||||
final Color accent;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.elevatedPanel.withValues(alpha: 0.42),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.62),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.inbox_outlined, color: accent, size: 22),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'No tasks here',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,961 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Renders Backlog board controls for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../../controllers/backlog_board_controller.dart';
|
||||
import '../../theme/backlog_board_visual_tokens.dart';
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
|
||||
/// Header controls for Compact/Board mode and settings.
|
||||
class BacklogModeAndSettingsControls extends StatelessWidget {
|
||||
/// Creates mode and settings controls.
|
||||
const BacklogModeAndSettingsControls({super.key});
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: const [
|
||||
_ModeSegment(label: 'Compact', active: false),
|
||||
_ModeSegment(label: 'Board', active: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.wb_sunny_outlined, size: 18),
|
||||
label: const Text('Settings'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
fixedSize: const Size(122, 42),
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Search, filter, sort, group, and new-task controls.
|
||||
class BacklogBoardTopControls extends StatefulWidget {
|
||||
/// Creates Backlog board top controls.
|
||||
const BacklogBoardTopControls({
|
||||
required this.controller,
|
||||
required this.onNewTask,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Controller updated by shell controls.
|
||||
final BacklogBoardController controller;
|
||||
|
||||
/// Callback invoked by the New Task button.
|
||||
final VoidCallback? onNewTask;
|
||||
|
||||
/// Creates the mutable state object used by this stateful widget.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@override
|
||||
State<BacklogBoardTopControls> createState() =>
|
||||
_BacklogBoardTopControlsState();
|
||||
}
|
||||
|
||||
/// State for Backlog board top controls.
|
||||
class _BacklogBoardTopControlsState extends State<BacklogBoardTopControls> {
|
||||
/// Text controller for the search field.
|
||||
late final TextEditingController _searchController;
|
||||
|
||||
/// Initializes text controller state from the current board controller.
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController(
|
||||
text: widget.controller.searchText ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Releases text controller resources.
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeFilterCount =
|
||||
widget.controller.filters.length +
|
||||
widget.controller.boardFilters.activeFilterCount;
|
||||
final searchText = widget.controller.searchText ?? '';
|
||||
if (searchText.isEmpty && _searchController.text.isNotEmpty) {
|
||||
_searchController.clear();
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: TextField(
|
||||
key: const ValueKey('backlog-search-field'),
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
unawaited(widget.controller.updateSearchText(value));
|
||||
},
|
||||
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: FocusFlowTokens.textMuted,
|
||||
),
|
||||
suffixIcon: searchText.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
key: const ValueKey('backlog-search-clear'),
|
||||
tooltip: 'Clear search',
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
unawaited(widget.controller.updateSearchText(null));
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: FocusFlowTokens.textMuted,
|
||||
),
|
||||
),
|
||||
hintText: 'Search backlog...',
|
||||
hintStyle: const TextStyle(color: FocusFlowTokens.textFaint),
|
||||
filled: true,
|
||||
fillColor: FocusFlowTokens.panelBackground,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(
|
||||
color: FocusFlowTokens.subtleBorder,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(
|
||||
color: FocusFlowTokens.accentMagenta,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_BacklogControlButton(
|
||||
key: const ValueKey('backlog-filter-button'),
|
||||
icon: Icons.filter_alt_outlined,
|
||||
label: activeFilterCount == 0
|
||||
? 'Filters'
|
||||
: 'Filters $activeFilterCount',
|
||||
onPressed: () {
|
||||
unawaited(_showFilterDialog(context, widget.controller));
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
_BacklogMenuControlButton<BacklogSortKey>(
|
||||
key: const ValueKey('backlog-sort-menu-button'),
|
||||
icon: Icons.tune,
|
||||
label: 'Sort: ${_sortLabel(widget.controller.sortKey)}',
|
||||
width: 158,
|
||||
selectedValue: widget.controller.sortKey,
|
||||
options: _sortOptions,
|
||||
onSelected: (value) {
|
||||
unawaited(widget.controller.updateSortKey(value));
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
_BacklogMenuControlButton<BacklogBoardGroupMode>(
|
||||
key: const ValueKey('backlog-group-menu-button'),
|
||||
icon: Icons.dashboard_customize_outlined,
|
||||
label: 'Group: ${_groupLabel(widget.controller.groupMode)}',
|
||||
width: 156,
|
||||
selectedValue: widget.controller.groupMode,
|
||||
options: _groupOptions,
|
||||
onSelected: (value) {
|
||||
unawaited(widget.controller.updateGroupMode(value));
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
FilledButton(
|
||||
key: const ValueKey('backlog-new-task-button'),
|
||||
onPressed: widget.onNewTask,
|
||||
style: FilledButton.styleFrom(
|
||||
fixedSize: const Size(146, 44),
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: FocusFlowTokens.accentMagenta,
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add, size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text('New Task'),
|
||||
SizedBox(width: 4),
|
||||
Icon(Icons.keyboard_arrow_down, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One segment in the Backlog mode toggle.
|
||||
class _ModeSegment extends StatelessWidget {
|
||||
/// Creates a mode segment.
|
||||
const _ModeSegment({required this.label, required this.active});
|
||||
|
||||
/// Segment label.
|
||||
final String label;
|
||||
|
||||
/// Whether this segment is selected.
|
||||
final bool active;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 78,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? FocusFlowTokens.glassPanelStrong : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: active
|
||||
? Border.all(color: FocusFlowTokens.navBorder)
|
||||
: Border.all(color: Colors.transparent),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: active
|
||||
? FocusFlowTokens.accentMagenta
|
||||
: FocusFlowTokens.textPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact outlined control button used by the Backlog shell.
|
||||
class _BacklogControlButton extends StatelessWidget {
|
||||
/// Creates a Backlog control button.
|
||||
const _BacklogControlButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Leading icon for the control.
|
||||
final IconData icon;
|
||||
|
||||
/// Control label.
|
||||
final String label;
|
||||
|
||||
/// Callback invoked when the control is pressed.
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 18),
|
||||
label: Text(label),
|
||||
style: OutlinedButton.styleFrom(
|
||||
fixedSize: const Size(132, 44),
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic popup menu button styled like the Backlog control row.
|
||||
class _BacklogMenuControlButton<T> extends StatelessWidget {
|
||||
/// Creates a popup menu control.
|
||||
const _BacklogMenuControlButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.width,
|
||||
required this.selectedValue,
|
||||
required this.options,
|
||||
required this.onSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Leading icon for the control.
|
||||
final IconData icon;
|
||||
|
||||
/// Button label.
|
||||
final String label;
|
||||
|
||||
/// Fixed button width.
|
||||
final double width;
|
||||
|
||||
/// Currently selected value.
|
||||
final T selectedValue;
|
||||
|
||||
/// Menu options.
|
||||
final List<_MenuOption<T>> options;
|
||||
|
||||
/// Selection callback.
|
||||
final ValueChanged<T> onSelected;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<T>(
|
||||
color: FocusFlowTokens.elevatedPanel,
|
||||
onSelected: onSelected,
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
for (final option in options)
|
||||
PopupMenuItem<T>(
|
||||
value: option.value,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
option.value == selectedValue
|
||||
? Icons.check
|
||||
: Icons.circle_outlined,
|
||||
size: 16,
|
||||
color: option.value == selectedValue
|
||||
? FocusFlowTokens.accentMagenta
|
||||
: FocusFlowTokens.textFaint,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
option.label,
|
||||
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
child: Container(
|
||||
width: width,
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: FocusFlowTokens.textPrimary),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: FocusFlowTokens.textMuted,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Menu option for a Backlog popup control.
|
||||
class _MenuOption<T> {
|
||||
/// Creates a menu option.
|
||||
const _MenuOption({required this.value, required this.label});
|
||||
|
||||
/// Option value.
|
||||
final T value;
|
||||
|
||||
/// Display label.
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// Result returned from the filter dialog.
|
||||
class _FilterDialogResult {
|
||||
/// Creates a filter dialog result.
|
||||
_FilterDialogResult({
|
||||
required List<BacklogFilter> legacyFilters,
|
||||
required this.boardFilters,
|
||||
}) : legacyFilters = List<BacklogFilter>.unmodifiable(legacyFilters);
|
||||
|
||||
/// Legacy backlog filters to apply.
|
||||
final List<BacklogFilter> legacyFilters;
|
||||
|
||||
/// Board-specific filters to apply.
|
||||
final BacklogBoardFilterSelection boardFilters;
|
||||
}
|
||||
|
||||
/// Dialog for editing Backlog board filters.
|
||||
class _BacklogFilterDialog extends StatefulWidget {
|
||||
/// Creates a Backlog filter dialog.
|
||||
const _BacklogFilterDialog({
|
||||
required this.initialLegacyFilters,
|
||||
required this.initialBoardFilters,
|
||||
required this.projectOptions,
|
||||
});
|
||||
|
||||
/// Legacy filters active before the dialog opened.
|
||||
final List<BacklogFilter> initialLegacyFilters;
|
||||
|
||||
/// Board filters active before the dialog opened.
|
||||
final BacklogBoardFilterSelection initialBoardFilters;
|
||||
|
||||
/// Project options available to the board.
|
||||
final List<ProjectOptionReadModel> projectOptions;
|
||||
|
||||
/// Creates the mutable state object used by this stateful widget.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@override
|
||||
State<_BacklogFilterDialog> createState() => _BacklogFilterDialogState();
|
||||
}
|
||||
|
||||
/// State for the Backlog filter dialog.
|
||||
class _BacklogFilterDialogState extends State<_BacklogFilterDialog> {
|
||||
/// Selected priority groups.
|
||||
late Set<BacklogBoardPriorityFilter> _priorityGroups;
|
||||
|
||||
/// Selected project ids.
|
||||
late Set<String> _projectIds;
|
||||
|
||||
/// Selected duration buckets.
|
||||
late Set<BacklogBoardDurationFilter> _durationBuckets;
|
||||
|
||||
/// Selected board buckets.
|
||||
late Set<BacklogBoardBucket> _buckets;
|
||||
|
||||
/// Whether missing-duration tasks are selected.
|
||||
late bool _missingDurationOnly;
|
||||
|
||||
/// Whether no-reward tasks are selected.
|
||||
late bool _noRewardSetOnly;
|
||||
|
||||
/// Initializes mutable filter selections.
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_priorityGroups = {...widget.initialBoardFilters.priorityGroups};
|
||||
_projectIds = {...widget.initialBoardFilters.projectIds};
|
||||
_durationBuckets = {...widget.initialBoardFilters.durationBuckets};
|
||||
_buckets = {...widget.initialBoardFilters.buckets};
|
||||
_missingDurationOnly = widget.initialBoardFilters.missingDurationOnly;
|
||||
_noRewardSetOnly = widget.initialLegacyFilters.contains(
|
||||
BacklogFilter.noRewardSet,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: FocusFlowTokens.elevatedPanel,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: const BorderSide(color: FocusFlowTokens.subtleBorder),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 680),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 20, 22, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.filter_alt_outlined,
|
||||
color: FocusFlowTokens.accentMagenta,
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Filters',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_FilterSection(
|
||||
title: 'Priority',
|
||||
children: [
|
||||
_checkbox(
|
||||
label: 'High priority',
|
||||
value: _priorityGroups.contains(
|
||||
BacklogBoardPriorityFilter.high,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_toggleSet(
|
||||
_priorityGroups,
|
||||
BacklogBoardPriorityFilter.high,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
_checkbox(
|
||||
label: 'Medium priority',
|
||||
value: _priorityGroups.contains(
|
||||
BacklogBoardPriorityFilter.medium,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_toggleSet(
|
||||
_priorityGroups,
|
||||
BacklogBoardPriorityFilter.medium,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
_checkbox(
|
||||
label: 'Low priority',
|
||||
value: _priorityGroups.contains(
|
||||
BacklogBoardPriorityFilter.low,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_toggleSet(
|
||||
_priorityGroups,
|
||||
BacklogBoardPriorityFilter.low,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
_FilterSection(
|
||||
title: 'Project',
|
||||
children: widget.projectOptions.isEmpty
|
||||
? const [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'No projects loaded',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
for (final project in widget.projectOptions)
|
||||
_checkbox(
|
||||
label: project.name,
|
||||
dotColor:
|
||||
BacklogBoardVisualTokens.projectColor(
|
||||
project.colorToken,
|
||||
),
|
||||
value: _projectIds.contains(
|
||||
project.projectId,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_toggleSet(
|
||||
_projectIds,
|
||||
project.projectId,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
_FilterSection(
|
||||
title: 'Duration',
|
||||
children: [
|
||||
_checkbox(
|
||||
label: '0-30 min',
|
||||
value: _durationBuckets.contains(
|
||||
BacklogBoardDurationFilter.zeroToThirty,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_toggleSet(
|
||||
_durationBuckets,
|
||||
BacklogBoardDurationFilter.zeroToThirty,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
_checkbox(
|
||||
label: '31-60 min',
|
||||
value: _durationBuckets.contains(
|
||||
BacklogBoardDurationFilter.thirtyToSixty,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_toggleSet(
|
||||
_durationBuckets,
|
||||
BacklogBoardDurationFilter.thirtyToSixty,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
_checkbox(
|
||||
label: '61-120 min',
|
||||
value: _durationBuckets.contains(
|
||||
BacklogBoardDurationFilter.sixtyToOneTwenty,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_toggleSet(
|
||||
_durationBuckets,
|
||||
BacklogBoardDurationFilter.sixtyToOneTwenty,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
_checkbox(
|
||||
label: '120+ min',
|
||||
value: _durationBuckets.contains(
|
||||
BacklogBoardDurationFilter.overOneTwenty,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_toggleSet(
|
||||
_durationBuckets,
|
||||
BacklogBoardDurationFilter.overOneTwenty,
|
||||
value,
|
||||
);
|
||||
},
|
||||
),
|
||||
_checkbox(
|
||||
label: 'Missing duration',
|
||||
value: _missingDurationOnly,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_missingDurationOnly = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
_FilterSection(
|
||||
title: 'Column',
|
||||
children: [
|
||||
for (final bucket in BacklogBoardBucket.values)
|
||||
_checkbox(
|
||||
label: _bucketLabel(bucket),
|
||||
dotColor: BacklogBoardVisualTokens.bucketAccent(
|
||||
bucket,
|
||||
),
|
||||
value: _buckets.contains(bucket),
|
||||
onChanged: (value) {
|
||||
_toggleSet(_buckets, bucket, value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
_FilterSection(
|
||||
title: 'Cleanup',
|
||||
children: [
|
||||
_checkbox(
|
||||
label: 'No reward set',
|
||||
value: _noRewardSetOnly,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_noRewardSetOnly = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(
|
||||
_FilterDialogResult(
|
||||
legacyFilters: const [],
|
||||
boardFilters:
|
||||
const BacklogBoardFilterSelection.empty(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(_result());
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: FocusFlowTokens.accentMagenta,
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a styled filter checkbox row.
|
||||
Widget _checkbox({
|
||||
required String label,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
Color? dotColor,
|
||||
}) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: CheckboxListTile(
|
||||
dense: true,
|
||||
value: value,
|
||||
onChanged: (next) {
|
||||
onChanged(next ?? false);
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: FocusFlowTokens.accentMagenta,
|
||||
checkColor: FocusFlowTokens.textPrimary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Row(
|
||||
children: [
|
||||
if (dotColor != null) ...[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Toggles [value] in [set] and rebuilds the dialog.
|
||||
void _toggleSet<T>(Set<T> set, T value, bool selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
set.add(value);
|
||||
} else {
|
||||
set.remove(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds the immutable result for the current dialog selections.
|
||||
_FilterDialogResult _result() {
|
||||
return _FilterDialogResult(
|
||||
legacyFilters: [if (_noRewardSetOnly) BacklogFilter.noRewardSet],
|
||||
boardFilters: BacklogBoardFilterSelection(
|
||||
priorityGroups: _priorityGroups,
|
||||
projectIds: _projectIds,
|
||||
durationBuckets: _durationBuckets,
|
||||
buckets: _buckets,
|
||||
missingDurationOnly: _missingDurationOnly,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Section wrapper inside the filter dialog.
|
||||
class _FilterSection extends StatelessWidget {
|
||||
/// Creates a filter section.
|
||||
const _FilterSection({required this.title, required this.children});
|
||||
|
||||
/// Section title.
|
||||
final String title;
|
||||
|
||||
/// Section children.
|
||||
final List<Widget> children;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.panelBackground.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the filter dialog and applies the returned result.
|
||||
Future<void> _showFilterDialog(
|
||||
BuildContext context,
|
||||
BacklogBoardController controller,
|
||||
) async {
|
||||
final result = await showDialog<_FilterDialogResult>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return _BacklogFilterDialog(
|
||||
initialLegacyFilters: controller.filters,
|
||||
initialBoardFilters: controller.boardFilters,
|
||||
projectOptions: _projectOptionsForState(controller.state),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
await controller.updateFilterState(
|
||||
filters: result.legacyFilters,
|
||||
boardFilters: result.boardFilters,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns project options from the current ready or empty board state.
|
||||
List<ProjectOptionReadModel> _projectOptionsForState(
|
||||
BacklogBoardScreenState state,
|
||||
) {
|
||||
return switch (state) {
|
||||
BacklogBoardReady(:final data) => data.board.projectOptions,
|
||||
BacklogBoardEmpty(:final data) => data.board.projectOptions,
|
||||
BacklogBoardLoading() ||
|
||||
BacklogBoardFailure() => const <ProjectOptionReadModel>[],
|
||||
};
|
||||
}
|
||||
|
||||
/// Display label for [sortKey].
|
||||
String _sortLabel(BacklogSortKey sortKey) {
|
||||
return switch (sortKey) {
|
||||
BacklogSortKey.custom => 'Custom',
|
||||
BacklogSortKey.priority => 'Priority',
|
||||
BacklogSortKey.rewardVsEffort => 'Reward',
|
||||
BacklogSortKey.age => 'Age',
|
||||
BacklogSortKey.project => 'Project',
|
||||
BacklogSortKey.timesPushed => 'Pushed',
|
||||
BacklogSortKey.duration => 'Duration',
|
||||
};
|
||||
}
|
||||
|
||||
/// Display label for [groupMode].
|
||||
String _groupLabel(BacklogBoardGroupMode groupMode) {
|
||||
return switch (groupMode) {
|
||||
BacklogBoardGroupMode.none => 'None',
|
||||
BacklogBoardGroupMode.project => 'Project',
|
||||
BacklogBoardGroupMode.priority => 'Priority',
|
||||
BacklogBoardGroupMode.duration => 'Duration',
|
||||
};
|
||||
}
|
||||
|
||||
/// Display label for [bucket].
|
||||
String _bucketLabel(BacklogBoardBucket bucket) {
|
||||
return switch (bucket) {
|
||||
BacklogBoardBucket.doNext => 'Do Next',
|
||||
BacklogBoardBucket.needTimeBlock => 'Need Time Block',
|
||||
BacklogBoardBucket.breakUpFirst => 'Break Up First',
|
||||
BacklogBoardBucket.notNow => 'Not Now',
|
||||
};
|
||||
}
|
||||
|
||||
/// Sort menu options in display order.
|
||||
const _sortOptions = [
|
||||
_MenuOption(value: BacklogSortKey.custom, label: 'Custom'),
|
||||
_MenuOption(value: BacklogSortKey.priority, label: 'Priority'),
|
||||
_MenuOption(value: BacklogSortKey.rewardVsEffort, label: 'Reward vs Effort'),
|
||||
_MenuOption(value: BacklogSortKey.age, label: 'Age'),
|
||||
_MenuOption(value: BacklogSortKey.project, label: 'Project'),
|
||||
_MenuOption(value: BacklogSortKey.timesPushed, label: 'Times Pushed'),
|
||||
_MenuOption(value: BacklogSortKey.duration, label: 'Duration'),
|
||||
];
|
||||
|
||||
/// Group menu options in display order.
|
||||
const _groupOptions = [
|
||||
_MenuOption(value: BacklogBoardGroupMode.none, label: 'None'),
|
||||
_MenuOption(value: BacklogBoardGroupMode.project, label: 'Project'),
|
||||
_MenuOption(value: BacklogBoardGroupMode.priority, label: 'Priority'),
|
||||
_MenuOption(value: BacklogBoardGroupMode.duration, label: 'Duration'),
|
||||
];
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,657 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Renders the Backlog board summary panel for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../../theme/backlog_board_visual_tokens.dart';
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
|
||||
/// Right-side Backlog summary panel driven by board summary read models.
|
||||
class BacklogSummaryPanel extends StatefulWidget {
|
||||
/// Creates a Backlog summary panel.
|
||||
const BacklogSummaryPanel({required this.summary, super.key});
|
||||
|
||||
/// Summary read model to display.
|
||||
final BacklogBoardSummaryReadModel summary;
|
||||
|
||||
/// Creates the mutable state object used by this stateful widget.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@override
|
||||
State<BacklogSummaryPanel> createState() => _BacklogSummaryPanelState();
|
||||
}
|
||||
|
||||
/// State for the Backlog summary panel collapse control.
|
||||
class _BacklogSummaryPanelState extends State<BacklogSummaryPanel> {
|
||||
/// Whether summary body sections are visible.
|
||||
var _expanded = true;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.panelBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: FocusFlowTokens.subtleBorder),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SummaryHeader(
|
||||
expanded: _expanded,
|
||||
onToggle: () {
|
||||
setState(() {
|
||||
_expanded = !_expanded;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const SizedBox(height: 22),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SummaryTotals(summary: widget.summary),
|
||||
const SizedBox(height: 22),
|
||||
_PriorityDistribution(
|
||||
distribution: widget.summary.priorityDistribution,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_ProjectDistribution(
|
||||
distribution: widget.summary.projectDistribution,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_DurationDistribution(
|
||||
distribution: widget.summary.durationDistribution,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_PlanningTip(text: widget.summary.planningTip),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Header row for the summary panel.
|
||||
class _SummaryHeader extends StatelessWidget {
|
||||
/// Creates a summary header.
|
||||
const _SummaryHeader({required this.expanded, required this.onToggle});
|
||||
|
||||
/// Whether body sections are visible.
|
||||
final bool expanded;
|
||||
|
||||
/// Callback invoked by the collapse button.
|
||||
final VoidCallback onToggle;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.auto_awesome, color: FocusFlowTokens.accentMagenta),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Backlog summary',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const ValueKey('backlog-summary-collapse-button'),
|
||||
tooltip: expanded ? 'Collapse summary' : 'Expand summary',
|
||||
onPressed: onToggle,
|
||||
icon: Icon(expanded ? Icons.expand_less : Icons.expand_more),
|
||||
color: FocusFlowTokens.textMuted,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Total task and estimated-time metrics.
|
||||
class _SummaryTotals extends StatelessWidget {
|
||||
/// Creates summary totals.
|
||||
const _SummaryTotals({required this.summary});
|
||||
|
||||
/// Summary data.
|
||||
final BacklogBoardSummaryReadModel summary;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryMetric(
|
||||
value: '${summary.totalTaskCount}',
|
||||
label: 'Total tasks',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _SummaryMetric(
|
||||
value: _estimatedTimeText(summary.totalEstimatedMinutes),
|
||||
label: 'Total estimated time',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One numeric metric in the summary panel.
|
||||
class _SummaryMetric extends StatelessWidget {
|
||||
/// Creates a summary metric.
|
||||
const _SummaryMetric({required this.value, required this.label});
|
||||
|
||||
/// Metric value.
|
||||
final String value;
|
||||
|
||||
/// Metric label.
|
||||
final String label;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textFaint,
|
||||
fontSize: 11.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Priority distribution section.
|
||||
class _PriorityDistribution extends StatelessWidget {
|
||||
/// Creates a priority distribution section.
|
||||
const _PriorityDistribution({required this.distribution});
|
||||
|
||||
/// Distribution read model.
|
||||
final BacklogDistributionReadModel distribution;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _DistributionSection(
|
||||
title: 'By priority',
|
||||
children: [
|
||||
for (final entry in distribution.entries)
|
||||
_DistributionRow(
|
||||
leading: Icon(
|
||||
Icons.flag,
|
||||
color: BacklogBoardVisualTokens.priorityColorToken(
|
||||
entry.colorToken,
|
||||
),
|
||||
size: 15,
|
||||
),
|
||||
label: entry.label,
|
||||
count: entry.count,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Project distribution section.
|
||||
class _ProjectDistribution extends StatelessWidget {
|
||||
/// Creates a project distribution section.
|
||||
const _ProjectDistribution({required this.distribution});
|
||||
|
||||
/// Distribution read model.
|
||||
final BacklogDistributionReadModel distribution;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _DistributionSection(
|
||||
title: 'By project',
|
||||
children: [
|
||||
for (final entry in distribution.entries)
|
||||
_DistributionRow(
|
||||
leading: _ColorDot(
|
||||
color: BacklogBoardVisualTokens.projectColor(
|
||||
entry.colorToken ?? entry.id,
|
||||
),
|
||||
),
|
||||
label: entry.label,
|
||||
count: entry.count,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared distribution section shell.
|
||||
class _DistributionSection extends StatelessWidget {
|
||||
/// Creates a distribution section.
|
||||
const _DistributionSection({required this.title, required this.children});
|
||||
|
||||
/// Section title.
|
||||
final String title;
|
||||
|
||||
/// Distribution row widgets.
|
||||
final List<Widget> children;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
if (children.isEmpty)
|
||||
const Text(
|
||||
'No tasks',
|
||||
style: TextStyle(color: FocusFlowTokens.textFaint, fontSize: 12),
|
||||
)
|
||||
else
|
||||
...children,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One compact distribution row.
|
||||
class _DistributionRow extends StatelessWidget {
|
||||
/// Creates a distribution row.
|
||||
const _DistributionRow({
|
||||
required this.leading,
|
||||
required this.label,
|
||||
required this.count,
|
||||
});
|
||||
|
||||
/// Leading icon or dot.
|
||||
final Widget leading;
|
||||
|
||||
/// Row label.
|
||||
final String label;
|
||||
|
||||
/// Row count.
|
||||
final int count;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 18, child: Center(child: leading)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$count',
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Colored project dot.
|
||||
class _ColorDot extends StatelessWidget {
|
||||
/// Creates a color dot.
|
||||
const _ColorDot({required this.color});
|
||||
|
||||
/// Dot color.
|
||||
final Color color;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 9,
|
||||
height: 9,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Duration donut chart and legend section.
|
||||
class _DurationDistribution extends StatelessWidget {
|
||||
/// Creates a duration distribution section.
|
||||
const _DurationDistribution({required this.distribution});
|
||||
|
||||
/// Distribution read model.
|
||||
final BacklogDistributionReadModel distribution;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entries = _durationEntries(distribution);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Duration distribution',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const Text(
|
||||
'Estimated time',
|
||||
style: TextStyle(color: FocusFlowTokens.textFaint, fontSize: 11.5),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 88,
|
||||
height: 88,
|
||||
child: CustomPaint(
|
||||
painter: _DurationDonutPainter(entries: entries),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final entry in entries) _DurationLegendRow(entry: entry),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One normalized duration chart entry.
|
||||
class _DurationChartEntry {
|
||||
/// Creates a duration chart entry.
|
||||
const _DurationChartEntry({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.percentage,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
/// Stable entry id.
|
||||
final String id;
|
||||
|
||||
/// Display label.
|
||||
final String label;
|
||||
|
||||
/// Task count in this duration bucket.
|
||||
final int count;
|
||||
|
||||
/// Share of total distribution.
|
||||
final double percentage;
|
||||
|
||||
/// Chart color.
|
||||
final Color color;
|
||||
}
|
||||
|
||||
/// Legend row for one duration chart entry.
|
||||
class _DurationLegendRow extends StatelessWidget {
|
||||
/// Creates a duration legend row.
|
||||
const _DurationLegendRow({required this.entry});
|
||||
|
||||
/// Chart entry to render.
|
||||
final _DurationChartEntry entry;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 7),
|
||||
child: Row(
|
||||
children: [
|
||||
_ColorDot(color: entry.color),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 11.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${entry.count} (${_percentageLabel(entry.percentage)})',
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Painter for the duration donut chart.
|
||||
class _DurationDonutPainter extends CustomPainter {
|
||||
/// Creates a duration donut painter.
|
||||
const _DurationDonutPainter({required this.entries});
|
||||
|
||||
/// Entries to paint.
|
||||
final List<_DurationChartEntry> entries;
|
||||
|
||||
/// Performs the paint behavior for this scheduler component.
|
||||
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final strokeWidth = size.shortestSide * 0.18;
|
||||
final rect = Offset.zero & size;
|
||||
final arcRect = rect.deflate(strokeWidth / 2);
|
||||
final basePaint = Paint()
|
||||
..color = FocusFlowTokens.subtleBorder.withValues(alpha: 0.62)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth;
|
||||
final radius = math.min(arcRect.width, arcRect.height) / 2;
|
||||
canvas.drawCircle(rect.center, radius, basePaint);
|
||||
|
||||
final total = entries.fold<int>(0, (sum, entry) => sum + entry.count);
|
||||
if (total == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
var startAngle = -math.pi / 2;
|
||||
for (final entry in entries) {
|
||||
if (entry.count == 0) {
|
||||
continue;
|
||||
}
|
||||
final sweep = math.pi * 2 * entry.percentage;
|
||||
final paint = Paint()
|
||||
..color = entry.color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.butt;
|
||||
canvas.drawArc(arcRect, startAngle, sweep, false, paint);
|
||||
startAngle += sweep;
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs the shouldRepaint behavior for this scheduler component.
|
||||
/// The method provides a named entry point for this operation so callers do not need to know the lower-level state or persistence steps involved.
|
||||
@override
|
||||
bool shouldRepaint(covariant _DurationDonutPainter oldDelegate) {
|
||||
return oldDelegate.entries != entries;
|
||||
}
|
||||
}
|
||||
|
||||
/// Planning tip card.
|
||||
class _PlanningTip extends StatelessWidget {
|
||||
/// Creates a planning tip card.
|
||||
const _PlanningTip({required this.text});
|
||||
|
||||
/// Tip body text.
|
||||
final String text;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: FocusFlowTokens.elevatedPanel,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.38),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_awesome,
|
||||
color: FocusFlowTokens.accentMagenta,
|
||||
size: 18,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Planning tip',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.accentMagenta,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
height: 1.38,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds normalized duration chart entries from [distribution].
|
||||
List<_DurationChartEntry> _durationEntries(
|
||||
BacklogDistributionReadModel distribution,
|
||||
) {
|
||||
final byId = {for (final entry in distribution.entries) entry.id: entry};
|
||||
return [
|
||||
for (final id in const ['0-30', '30-60', '60-120', '120+'])
|
||||
_DurationChartEntry(
|
||||
id: id,
|
||||
label: _durationLabel(id, byId[id]?.label ?? id),
|
||||
count: byId[id]?.count ?? 0,
|
||||
percentage: byId[id]?.percentage ?? 0,
|
||||
color: BacklogBoardVisualTokens.durationColor(id),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Resolves a display label for a duration distribution [id].
|
||||
String _durationLabel(String id, String fallback) {
|
||||
return switch (id) {
|
||||
'0-30' => '0-30 min',
|
||||
'30-60' => '30-60 min',
|
||||
'60-120' => '60-120 min',
|
||||
'120+' => '120+ min',
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
|
||||
/// Formats [minutes] as compact hour/minute text.
|
||||
String _estimatedTimeText(int minutes) {
|
||||
final hours = minutes ~/ 60;
|
||||
final remainder = minutes % 60;
|
||||
if (hours == 0) {
|
||||
return '${remainder}m';
|
||||
}
|
||||
if (remainder == 0) {
|
||||
return '${hours}h';
|
||||
}
|
||||
return '${hours}h ${remainder}m';
|
||||
}
|
||||
|
||||
/// Formats [percentage] for legend display.
|
||||
String _percentageLabel(double percentage) {
|
||||
return '${(percentage * 100).round()}%';
|
||||
}
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Renders Backlog board task cards for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../../theme/backlog_board_visual_tokens.dart';
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
import '../timeline/difficulty_bars.dart';
|
||||
import '../timeline/reward_icon.dart';
|
||||
|
||||
/// Card widget for one Backlog board task read model.
|
||||
class BacklogTaskCard extends StatelessWidget {
|
||||
/// Creates a Backlog task card.
|
||||
const BacklogTaskCard({
|
||||
required this.item,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Read model that supplies all card content.
|
||||
final BacklogBoardItemReadModel item;
|
||||
|
||||
/// Whether this card is the current UI selection.
|
||||
final bool selected;
|
||||
|
||||
/// Callback invoked when the user selects the card.
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = BacklogBoardVisualTokens.bucketAccent(item.bucket);
|
||||
final subtitle = item.subtitle ?? item.bucketReason;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
key: ValueKey('backlog-card-${item.taskId}'),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AnimatedContainer(
|
||||
key: ValueKey('backlog-card-container-${item.taskId}'),
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 11),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
FocusFlowTokens.elevatedPanel,
|
||||
accent.withValues(alpha: 0.06),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? FocusFlowTokens.accentMagenta
|
||||
: FocusFlowTokens.subtleBorder,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_CardTitleRow(item: item, selected: selected),
|
||||
if (subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 5),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24),
|
||||
child: Text(
|
||||
subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 11,
|
||||
height: 1.22,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
_CardMetadataRow(item: item),
|
||||
const SizedBox(height: 10),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.55),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
_CardEffortRow(item: item, accent: accent),
|
||||
if (item.childPreview.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
_ChildPreviewSection(children: item.childPreview),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Header row with checkbox-like selection square and task title.
|
||||
class _CardTitleRow extends StatelessWidget {
|
||||
/// Creates a title row for [item].
|
||||
const _CardTitleRow({required this.item, required this.selected});
|
||||
|
||||
/// Task card data to display.
|
||||
final BacklogBoardItemReadModel item;
|
||||
|
||||
/// Whether this card is selected.
|
||||
final bool selected;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 15,
|
||||
height: 15,
|
||||
margin: const EdgeInsets.only(top: 2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? FocusFlowTokens.accentMagenta
|
||||
: FocusFlowTokens.textFaint,
|
||||
width: selected ? 1.8 : 1.2,
|
||||
),
|
||||
color: selected
|
||||
? FocusFlowTokens.accentMagenta.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: selected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
color: FocusFlowTokens.accentMagenta,
|
||||
size: 11,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Row of duration, project, and priority metadata.
|
||||
class _CardMetadataRow extends StatelessWidget {
|
||||
/// Creates a metadata row for [item].
|
||||
const _CardMetadataRow({required this.item});
|
||||
|
||||
/// Task card data to display.
|
||||
final BacklogBoardItemReadModel item;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectColor = BacklogBoardVisualTokens.projectColor(
|
||||
item.projectColorToken,
|
||||
);
|
||||
final priorityColor = BacklogBoardVisualTokens.priorityColor(item.priority);
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 7,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_IconMetadata(
|
||||
icon: Icons.schedule,
|
||||
color: FocusFlowTokens.textMuted,
|
||||
label: _durationLabel(item.durationMinutes),
|
||||
),
|
||||
_DotMetadata(color: projectColor, label: item.projectName),
|
||||
_IconMetadata(
|
||||
icon: Icons.flag,
|
||||
color: priorityColor,
|
||||
label: BacklogBoardVisualTokens.priorityLabel(item.priority),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact metadata item with a Material icon.
|
||||
class _IconMetadata extends StatelessWidget {
|
||||
/// Creates icon metadata.
|
||||
const _IconMetadata({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
/// Icon shown before the label.
|
||||
final IconData icon;
|
||||
|
||||
/// Color used by the icon.
|
||||
final Color color;
|
||||
|
||||
/// Display label.
|
||||
final String label;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 13),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact metadata item with a color dot.
|
||||
class _DotMetadata extends StatelessWidget {
|
||||
/// Creates dot metadata.
|
||||
const _DotMetadata({required this.color, required this.label});
|
||||
|
||||
/// Dot color.
|
||||
final Color color;
|
||||
|
||||
/// Display label.
|
||||
final String label;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Footer row with difficulty bars and reward marks.
|
||||
class _CardEffortRow extends StatelessWidget {
|
||||
/// Creates an effort row for [item].
|
||||
const _CardEffortRow({required this.item, required this.accent});
|
||||
|
||||
/// Task card data to display.
|
||||
final BacklogBoardItemReadModel item;
|
||||
|
||||
/// Bucket accent used for compact indicators.
|
||||
final Color accent;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetricCluster(
|
||||
label: 'Difficulty',
|
||||
child: DifficultyBars.forLevel(
|
||||
difficulty: item.difficulty,
|
||||
color: accent,
|
||||
size: const Size(36, 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _MetricCluster(
|
||||
label: 'Reward',
|
||||
alignment: MainAxisAlignment.end,
|
||||
child: _RewardMarks(reward: item.reward, color: accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Label-and-value cluster used by the card footer.
|
||||
class _MetricCluster extends StatelessWidget {
|
||||
/// Creates a compact metric cluster.
|
||||
const _MetricCluster({
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.alignment = MainAxisAlignment.start,
|
||||
});
|
||||
|
||||
/// Metric label.
|
||||
final String label;
|
||||
|
||||
/// Indicator widget.
|
||||
final Widget child;
|
||||
|
||||
/// Horizontal alignment for the cluster content.
|
||||
final MainAxisAlignment alignment;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: alignment,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Solid reward marks rendered from a domain reward level.
|
||||
class _RewardMarks extends StatelessWidget {
|
||||
/// Creates reward marks.
|
||||
const _RewardMarks({required this.reward, required this.color});
|
||||
|
||||
/// Reward level to display.
|
||||
final RewardLevel reward;
|
||||
|
||||
/// Mark color.
|
||||
final Color color;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final count = BacklogBoardVisualTokens.rewardCount(reward);
|
||||
if (count == 0) {
|
||||
return const Text(
|
||||
'None',
|
||||
style: TextStyle(color: FocusFlowTokens.textFaint, fontSize: 10.5),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var index = 0; index < count; index += 1)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: index == 0 ? 0 : 1.5),
|
||||
child: RewardIcon(color: color, size: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nested child preview rows for parent Backlog cards.
|
||||
class _ChildPreviewSection extends StatelessWidget {
|
||||
/// Creates a child preview section.
|
||||
const _ChildPreviewSection({required this.children});
|
||||
|
||||
/// Child preview read models.
|
||||
final List<BacklogChildPreviewReadModel> children;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: FocusFlowTokens.subtleBorder.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final child in children)
|
||||
_ChildPreviewRow(
|
||||
key: ValueKey('backlog-child-${child.taskId}'),
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One nested child preview row inside a Backlog task card.
|
||||
class _ChildPreviewRow extends StatelessWidget {
|
||||
/// Creates a child preview row.
|
||||
const _ChildPreviewRow({required this.child, super.key});
|
||||
|
||||
/// Child preview data.
|
||||
final BacklogChildPreviewReadModel child;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 7),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
child.isCompleted
|
||||
? Icons.check_box_outlined
|
||||
: Icons.check_box_outline_blank,
|
||||
color: FocusFlowTokens.textFaint,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
child.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: child.isCompleted
|
||||
? FocusFlowTokens.completedText
|
||||
: FocusFlowTokens.textMuted,
|
||||
fontSize: 11,
|
||||
decoration: child.isCompleted
|
||||
? TextDecoration.lineThrough
|
||||
: TextDecoration.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (child.durationMinutes != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_IconMetadata(
|
||||
icon: Icons.schedule,
|
||||
color: FocusFlowTokens.textFaint,
|
||||
label: _durationLabel(child.durationMinutes),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats [durationMinutes] as compact card metadata.
|
||||
String _durationLabel(int? durationMinutes) {
|
||||
if (durationMinutes == null) {
|
||||
return 'No estimate';
|
||||
}
|
||||
if (durationMinutes < 60) {
|
||||
return '$durationMinutes min';
|
||||
}
|
||||
final hours = durationMinutes ~/ 60;
|
||||
final minutes = durationMinutes % 60;
|
||||
if (minutes == 0) {
|
||||
return '${hours}h';
|
||||
}
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
1311
apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart
Normal file
1311
apps/focus_flow_flutter/lib/widgets/backlog/backlog_task_drawer.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,182 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Renders the Backlog Pane widget for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/scheduler_read_controller.dart';
|
||||
import 'read_state_view.dart';
|
||||
import 'schedule_components.dart';
|
||||
|
||||
/// Read-driven pane that renders backlog content and command controls.
|
||||
class BacklogPane extends StatelessWidget {
|
||||
/// Creates a backlog pane from a read [controller].
|
||||
const BacklogPane({
|
||||
required this.controller,
|
||||
this.commandController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Controller that loads backlog data.
|
||||
final UiReadController<BacklogQueryResult> controller;
|
||||
|
||||
/// Optional command controller for mutating backlog items.
|
||||
final SchedulerCommandController? commandController;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReadStateView<BacklogQueryResult>(
|
||||
controller: controller,
|
||||
title: 'Backlog',
|
||||
emptyTitle: 'Backlog is clear',
|
||||
emptyMessage: 'Captured tasks will appear here when they need planning.',
|
||||
dataBuilder: (context, result) =>
|
||||
BacklogContent(result: result, commandController: commandController),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Populated backlog view for a loaded backlog query result.
|
||||
class BacklogContent extends StatelessWidget {
|
||||
/// Creates backlog content for [result].
|
||||
const BacklogContent({
|
||||
required this.result,
|
||||
this.commandController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Loaded backlog result to display.
|
||||
final BacklogQueryResult result;
|
||||
|
||||
/// Optional command controller for capture and scheduling actions.
|
||||
final SchedulerCommandController? commandController;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('Backlog', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 12),
|
||||
if (commandController != null) ...[
|
||||
QuickCaptureForm(commandController: commandController!),
|
||||
const SizedBox(height: 12),
|
||||
CommandStatusBanner(controller: commandController!),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
for (final item in result.items)
|
||||
BacklogRow(
|
||||
item: item,
|
||||
onSchedule: commandController == null
|
||||
? null
|
||||
: (durationMinutes) {
|
||||
return commandController!.scheduleBacklogItem(
|
||||
taskId: item.task.id,
|
||||
durationMinutes: durationMinutes,
|
||||
expectedUpdatedAt: item.task.updatedAt,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small form for quickly capturing a task into the backlog.
|
||||
class QuickCaptureForm extends StatefulWidget {
|
||||
/// Creates a quick capture form.
|
||||
const QuickCaptureForm({required this.commandController, super.key});
|
||||
|
||||
/// Command controller used to submit captured task titles.
|
||||
final SchedulerCommandController commandController;
|
||||
|
||||
/// Creates the mutable state object used by this stateful widget.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@override
|
||||
State<QuickCaptureForm> createState() => _QuickCaptureFormState();
|
||||
}
|
||||
|
||||
/// Private implementation type for `_QuickCaptureFormState` in this library.
|
||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||
class _QuickCaptureFormState extends State<QuickCaptureForm> {
|
||||
/// Stores the `titleController` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final titleController = TextEditingController();
|
||||
|
||||
/// Releases resources owned by this object before it leaves the tree.
|
||||
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
|
||||
@override
|
||||
void dispose() {
|
||||
titleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const ValueKey('quick-capture-title'),
|
||||
controller: titleController,
|
||||
decoration: const InputDecoration(labelText: 'Quick capture'),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
final title = titleController.text.trim();
|
||||
if (title.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await widget.commandController.quickCaptureToBacklog(title);
|
||||
titleController.clear();
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Capture'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the latest command state for backlog interactions.
|
||||
class CommandStatusBanner extends StatelessWidget {
|
||||
/// Creates a command status banner.
|
||||
const CommandStatusBanner({required this.controller, super.key});
|
||||
|
||||
/// Command controller whose state should be rendered.
|
||||
final SchedulerCommandController controller;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
return switch (controller.state) {
|
||||
SchedulerCommandIdle() => const SizedBox.shrink(),
|
||||
SchedulerCommandRunning(label: final label) => Text(label),
|
||||
SchedulerCommandSuccess(message: final message) => Text(message),
|
||||
SchedulerCommandFailure(failure: final failure, error: final error) =>
|
||||
Text(
|
||||
'Command issue: '
|
||||
'${failure?.detailCode ?? failure?.code.name ?? error ?? 'unexpected'}',
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
259
apps/focus_flow_flutter/lib/widgets/dialogs/break_up_dialog.dart
Normal file
259
apps/focus_flow_flutter/lib/widgets/dialogs/break_up_dialog.dart
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Renders the shared Break Up dialog for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
|
||||
/// Editable child-task row state owned by [BreakUpDialog].
|
||||
class _BreakUpRow {
|
||||
/// Creates an empty break-up row with fresh text controllers.
|
||||
_BreakUpRow()
|
||||
: titleController = TextEditingController(),
|
||||
durationController = TextEditingController();
|
||||
|
||||
/// Controls the row's child task title field.
|
||||
final TextEditingController titleController;
|
||||
|
||||
/// Controls the row's optional duration-in-minutes field.
|
||||
final TextEditingController durationController;
|
||||
|
||||
/// Optional priority selected for this row.
|
||||
PriorityLevel? priority;
|
||||
|
||||
/// Reward level selected for this row.
|
||||
RewardLevel reward = RewardLevel.notSet;
|
||||
|
||||
/// Releases the text controllers owned by this row.
|
||||
void dispose() {
|
||||
titleController.dispose();
|
||||
durationController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog that collects one or more child task drafts to break up a parent
|
||||
/// task, per MVP-AC-11 (row-level priority, reward, and duration).
|
||||
///
|
||||
/// Shared by the task selection modal and timeline card quick actions so
|
||||
/// both surfaces reuse the same break-up flow instead of duplicating it.
|
||||
class BreakUpDialog extends StatefulWidget {
|
||||
/// Creates a break-up dialog.
|
||||
const BreakUpDialog({super.key});
|
||||
|
||||
/// Creates the mutable state object used by this stateful widget.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@override
|
||||
State<BreakUpDialog> createState() => _BreakUpDialogState();
|
||||
}
|
||||
|
||||
/// Private implementation type for `_BreakUpDialogState` in this library.
|
||||
/// It owns the growable list of child-task rows shown by the dialog.
|
||||
class _BreakUpDialogState extends State<BreakUpDialog> {
|
||||
/// Editable rows currently shown in the dialog.
|
||||
final List<_BreakUpRow> _rows = [_BreakUpRow()];
|
||||
|
||||
/// Releases resources owned by this object before it leaves the tree.
|
||||
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
|
||||
@override
|
||||
void dispose() {
|
||||
for (final row in _rows) {
|
||||
row.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Whether at least one row has a non-empty title.
|
||||
bool get _canSubmit =>
|
||||
_rows.any((row) => row.titleController.text.trim().isNotEmpty);
|
||||
|
||||
/// Appends a new empty row.
|
||||
void _addRow() {
|
||||
setState(() => _rows.add(_BreakUpRow()));
|
||||
}
|
||||
|
||||
/// Removes the row at [index], keeping at least one row.
|
||||
void _removeRow(int index) {
|
||||
if (_rows.length <= 1) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_rows.removeAt(index).dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds child task drafts from rows with a non-empty title and closes
|
||||
/// the dialog with the resulting list.
|
||||
void _submit() {
|
||||
final drafts = <ApplicationChildTaskDraft>[
|
||||
for (final row in _rows)
|
||||
if (row.titleController.text.trim().isNotEmpty)
|
||||
ApplicationChildTaskDraft(
|
||||
title: row.titleController.text.trim(),
|
||||
priority: row.priority,
|
||||
reward: row.reward,
|
||||
durationMinutes: int.tryParse(row.durationController.text.trim()),
|
||||
),
|
||||
];
|
||||
Navigator.of(context).pop(drafts);
|
||||
}
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: FocusFlowTokens.elevatedPanel,
|
||||
title: const Text(
|
||||
'Break up task',
|
||||
style: TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 480,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < _rows.length; i++)
|
||||
_BreakUpRowFields(
|
||||
key: ValueKey('break-up-row-$i'),
|
||||
rowIndex: i,
|
||||
row: _rows[i],
|
||||
onChanged: () => setState(() {}),
|
||||
onRemove: _rows.length > 1 ? () => _removeRow(i) : null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
key: const ValueKey('break-up-add-row'),
|
||||
onPressed: _addRow,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add another task'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
key: const ValueKey('break-up-confirm'),
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
child: const Text('Break up'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Editable fields for a single [_BreakUpRow].
|
||||
class _BreakUpRowFields extends StatelessWidget {
|
||||
/// Creates fields bound to [row] at [rowIndex].
|
||||
const _BreakUpRowFields({
|
||||
required this.rowIndex,
|
||||
required this.row,
|
||||
required this.onChanged,
|
||||
this.onRemove,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Position of this row, used to build stable field keys.
|
||||
final int rowIndex;
|
||||
|
||||
/// Row whose fields are rendered and mutated in place.
|
||||
final _BreakUpRow row;
|
||||
|
||||
/// Invoked after any field in this row changes.
|
||||
final VoidCallback onChanged;
|
||||
|
||||
/// Invoked when this row should be removed. Null when removal is disabled.
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
key: ValueKey('break-up-row-$rowIndex-title'),
|
||||
controller: row.titleController,
|
||||
decoration: const InputDecoration(labelText: 'Task title'),
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: TextField(
|
||||
key: ValueKey('break-up-row-$rowIndex-duration'),
|
||||
controller: row.durationController,
|
||||
decoration: const InputDecoration(labelText: 'Min'),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (_) => onChanged(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 132,
|
||||
child: DropdownButtonFormField<PriorityLevel?>(
|
||||
key: ValueKey('break-up-row-$rowIndex-priority'),
|
||||
initialValue: row.priority,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Priority'),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Unset')),
|
||||
for (final level in PriorityLevel.values)
|
||||
DropdownMenuItem(value: level, child: Text(level.name)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
row.priority = value;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 132,
|
||||
child: DropdownButtonFormField<RewardLevel>(
|
||||
key: ValueKey('break-up-row-$rowIndex-reward'),
|
||||
initialValue: row.reward,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Reward'),
|
||||
items: [
|
||||
for (final level in RewardLevel.values)
|
||||
DropdownMenuItem(value: level, child: Text(level.name)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
row.reward = value;
|
||||
onChanged();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (onRemove != null)
|
||||
IconButton(
|
||||
tooltip: 'Remove row',
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
317
apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart
Normal file
317
apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Renders the shared Settings dialog for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
|
||||
/// Time zone codes recognized by the app's runtime config loader.
|
||||
const _timeZoneOptions = <String>[
|
||||
'UTC',
|
||||
'GMT',
|
||||
'PST',
|
||||
'PDT',
|
||||
'MST',
|
||||
'MDT',
|
||||
'CST',
|
||||
'CDT',
|
||||
'EST',
|
||||
'EDT',
|
||||
];
|
||||
|
||||
/// Dialog that surfaces persisted [OwnerSettings] fields for editing.
|
||||
///
|
||||
/// Returns an [OwnerSettingsUpdate] patch containing only the fields the user
|
||||
/// changed when confirmed, or `null` when cancelled.
|
||||
class SettingsDialog extends StatefulWidget {
|
||||
/// Creates a settings dialog seeded with the currently persisted [initial]
|
||||
/// settings.
|
||||
const SettingsDialog({required this.initial, super.key});
|
||||
|
||||
/// Currently persisted owner settings shown as the dialog's starting
|
||||
/// values.
|
||||
final OwnerSettings initial;
|
||||
|
||||
/// Creates the mutable state object used by this stateful widget.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@override
|
||||
State<SettingsDialog> createState() => _SettingsDialogState();
|
||||
}
|
||||
|
||||
/// Private implementation type for `_SettingsDialogState` in this library.
|
||||
class _SettingsDialogState extends State<SettingsDialog> {
|
||||
late String _timeZoneId;
|
||||
late WallTime _dayStart;
|
||||
late WallTime _dayEnd;
|
||||
late bool _compactModeEnabled;
|
||||
late final TextEditingController _freshDaysController;
|
||||
late final TextEditingController _agingDaysController;
|
||||
|
||||
/// Validation message for the day window, if any.
|
||||
String? _dayWindowError;
|
||||
|
||||
/// Validation message for the staleness thresholds, if any.
|
||||
String? _stalenessError;
|
||||
|
||||
/// Initializes state owned by this object before the first build.
|
||||
/// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initial;
|
||||
_timeZoneId = _timeZoneOptions.contains(initial.timeZoneId.toUpperCase())
|
||||
? initial.timeZoneId.toUpperCase()
|
||||
: _timeZoneOptions.first;
|
||||
_dayStart = initial.dayStart;
|
||||
_dayEnd = initial.dayEnd;
|
||||
_compactModeEnabled = initial.compactModeEnabled;
|
||||
_freshDaysController = TextEditingController(
|
||||
text: initial.backlogStalenessSettings.greenMaxAge.inDays.toString(),
|
||||
);
|
||||
_agingDaysController = TextEditingController(
|
||||
text: initial.backlogStalenessSettings.blueMaxAge.inDays.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Releases resources owned by this object before it leaves the tree.
|
||||
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
|
||||
@override
|
||||
void dispose() {
|
||||
_freshDaysController.dispose();
|
||||
_agingDaysController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickDayStart() async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay(hour: _dayStart.hour, minute: _dayStart.minute),
|
||||
);
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_dayStart = WallTime(hour: picked.hour, minute: picked.minute);
|
||||
_dayWindowError = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDayEnd() async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay(hour: _dayEnd.hour, minute: _dayEnd.minute),
|
||||
);
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_dayEnd = WallTime(hour: picked.hour, minute: picked.minute);
|
||||
_dayWindowError = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (_dayStart.compareTo(_dayEnd) >= 0) {
|
||||
setState(() => _dayWindowError = 'Day end must be after day start.');
|
||||
return;
|
||||
}
|
||||
final freshDays = int.tryParse(_freshDaysController.text.trim());
|
||||
final agingDays = int.tryParse(_agingDaysController.text.trim());
|
||||
if (freshDays == null ||
|
||||
agingDays == null ||
|
||||
freshDays < 0 ||
|
||||
agingDays < freshDays) {
|
||||
setState(
|
||||
() => _stalenessError =
|
||||
'Aging must be a number of days at least as large as Fresh.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(
|
||||
OwnerSettingsUpdate(
|
||||
timeZoneId: _timeZoneId,
|
||||
dayStart: _dayStart,
|
||||
dayEnd: _dayEnd,
|
||||
compactModeEnabled: _compactModeEnabled,
|
||||
backlogStalenessSettings: BacklogStalenessSettings(
|
||||
greenMaxAge: Duration(days: freshDays),
|
||||
blueMaxAge: Duration(days: agingDays),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: FocusFlowTokens.elevatedPanel,
|
||||
title: const Text(
|
||||
'Settings',
|
||||
style: TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Time & day',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
key: const ValueKey('settings-timezone'),
|
||||
initialValue: _timeZoneId,
|
||||
decoration: const InputDecoration(labelText: 'Time zone'),
|
||||
items: [
|
||||
for (final zone in _timeZoneOptions)
|
||||
DropdownMenuItem(value: zone, child: Text(zone)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
setState(() => _timeZoneId = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
key: const ValueKey('settings-day-start'),
|
||||
onPressed: _pickDayStart,
|
||||
child: Text('Day start: ${_dayStart.formatted}'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
key: const ValueKey('settings-day-end'),
|
||||
onPressed: _pickDayEnd,
|
||||
child: Text('Day end: ${_dayEnd.formatted}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_dayWindowError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_dayWindowError!,
|
||||
style: const TextStyle(color: FocusFlowTokens.accentMagenta),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Compact mode',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
key: const ValueKey('settings-compact-mode'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text(
|
||||
'Compact Today view',
|
||||
style: TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Saved now; visual toggle is coming soon.',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
value: _compactModeEnabled,
|
||||
onChanged: (value) {
|
||||
setState(() => _compactModeEnabled = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Backlog freshness',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const ValueKey('settings-fresh-days'),
|
||||
controller: _freshDaysController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Fresh (days)',
|
||||
),
|
||||
onChanged: (_) => setState(() => _stalenessError = null),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const ValueKey('settings-aging-days'),
|
||||
controller: _agingDaysController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Aging (days)',
|
||||
),
|
||||
onChanged: (_) => setState(() => _stalenessError = null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Anything older than Aging is shown as Stale.',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (_stalenessError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_stalenessError!,
|
||||
style: const TextStyle(color: FocusFlowTokens.accentMagenta),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
key: const ValueKey('settings-save'),
|
||||
onPressed: _submit,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a [WallTime] as `HH:MM`.
|
||||
extension on WallTime {
|
||||
String get formatted =>
|
||||
'${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
|
@ -6,12 +6,23 @@ library;
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/navigation/focus_flow_section.dart';
|
||||
import '../theme/focus_flow_tokens.dart';
|
||||
|
||||
/// Static sidebar for primary FocusFlow navigation.
|
||||
/// Sidebar for primary FocusFlow navigation.
|
||||
class Sidebar extends StatelessWidget {
|
||||
/// Creates the FocusFlow sidebar.
|
||||
const Sidebar({super.key});
|
||||
const Sidebar({
|
||||
required this.activeSection,
|
||||
required this.onSectionSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Currently active app section.
|
||||
final FocusFlowSection activeSection;
|
||||
|
||||
/// Callback invoked when a section is selected.
|
||||
final ValueChanged<FocusFlowSection> onSectionSelected;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
|
|
@ -29,19 +40,44 @@ class Sidebar extends StatelessWidget {
|
|||
_NavItem(
|
||||
label: 'Today',
|
||||
icon: Icons.calendar_today,
|
||||
active: true,
|
||||
onTap: () {},
|
||||
section: FocusFlowSection.today,
|
||||
active: activeSection == FocusFlowSection.today,
|
||||
onTap: onSectionSelected,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_NavItem(label: 'Backlog', icon: Icons.layers, onTap: () {}),
|
||||
_NavItem(
|
||||
label: 'Backlog',
|
||||
icon: Icons.layers,
|
||||
section: FocusFlowSection.backlog,
|
||||
active: activeSection == FocusFlowSection.backlog,
|
||||
onTap: onSectionSelected,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_NavItem(label: 'Projects', icon: Icons.work_outline, onTap: () {}),
|
||||
_NavItem(
|
||||
label: 'Projects',
|
||||
icon: Icons.work_outline,
|
||||
section: FocusFlowSection.projects,
|
||||
active: activeSection == FocusFlowSection.projects,
|
||||
onTap: onSectionSelected,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_NavItem(label: 'Reports', icon: Icons.bar_chart, onTap: () {}),
|
||||
_NavItem(
|
||||
label: 'Reports',
|
||||
icon: Icons.bar_chart,
|
||||
section: FocusFlowSection.reports,
|
||||
active: activeSection == FocusFlowSection.reports,
|
||||
onTap: onSectionSelected,
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
const Divider(color: FocusFlowTokens.subtleBorder),
|
||||
const SizedBox(height: 18),
|
||||
_NavItem(label: 'Settings', icon: Icons.settings, onTap: () {}),
|
||||
_NavItem(
|
||||
label: 'Settings',
|
||||
icon: Icons.settings,
|
||||
section: FocusFlowSection.settings,
|
||||
active: activeSection == FocusFlowSection.settings,
|
||||
onTap: onSectionSelected,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -97,6 +133,7 @@ class _NavItem extends StatelessWidget {
|
|||
const _NavItem({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.section,
|
||||
required this.onTap,
|
||||
this.active = false,
|
||||
});
|
||||
|
|
@ -109,9 +146,13 @@ class _NavItem extends StatelessWidget {
|
|||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final IconData icon;
|
||||
|
||||
/// Stores the `section` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final FocusFlowSection section;
|
||||
|
||||
/// Stores the `onTap` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final VoidCallback onTap;
|
||||
final ValueChanged<FocusFlowSection> onTap;
|
||||
|
||||
/// Stores the `active` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
|
|
@ -127,9 +168,11 @@ class _NavItem extends StatelessWidget {
|
|||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
key: active ? const ValueKey('nav-today-active') : null,
|
||||
key: ValueKey(
|
||||
active ? 'nav-${section.name}-active' : 'nav-${section.name}',
|
||||
),
|
||||
borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius),
|
||||
onTap: onTap,
|
||||
onTap: () => onTap(section),
|
||||
child: Container(
|
||||
height: 54,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ library;
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../models/today_screen_models.dart';
|
||||
import '../theme/focus_flow_tokens.dart';
|
||||
import 'dialogs/break_up_dialog.dart';
|
||||
import 'status_circle_button.dart';
|
||||
import 'timeline/difficulty_bars.dart';
|
||||
import 'timeline/reward_icon.dart';
|
||||
|
|
@ -31,6 +33,7 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
this.onRemove,
|
||||
this.onBreakUp,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -55,6 +58,26 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
/// Callback invoked when Remove is selected.
|
||||
final Future<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.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
|
|
@ -132,8 +155,21 @@ class TaskSelectionModal extends StatelessWidget {
|
|||
_ActionButton(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
label: 'Backlog',
|
||||
onTap: onPushToBacklog == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(onPushToBacklog!(card));
|
||||
},
|
||||
),
|
||||
_ActionButton(
|
||||
icon: Icons.apps,
|
||||
label: 'Break up',
|
||||
onTap: onBreakUp == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_handleBreakUp(context));
|
||||
},
|
||||
),
|
||||
const _ActionButton(icon: Icons.apps, label: 'Break up'),
|
||||
_ActionButton(
|
||||
icon: Icons.delete_outline,
|
||||
label: 'Remove',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,21 @@ class DifficultyBars extends StatelessWidget {
|
|||
super.key,
|
||||
});
|
||||
|
||||
/// Creates difficulty bars directly from a domain difficulty level.
|
||||
factory DifficultyBars.forLevel({
|
||||
required DifficultyLevel difficulty,
|
||||
Color color = FocusFlowTokens.restPurple,
|
||||
Size size = const Size(54, 34),
|
||||
Key? key,
|
||||
}) {
|
||||
return DifficultyBars(
|
||||
key: key,
|
||||
difficulty: tokenForLevel(difficulty),
|
||||
color: color,
|
||||
size: size,
|
||||
);
|
||||
}
|
||||
|
||||
/// Difficulty token that determines how many bars are filled.
|
||||
final TimelineDifficultyIconToken difficulty;
|
||||
|
||||
|
|
@ -52,6 +67,18 @@ class DifficultyBars extends StatelessWidget {
|
|||
};
|
||||
}
|
||||
|
||||
/// Converts a domain difficulty level to a timeline difficulty token.
|
||||
static TimelineDifficultyIconToken tokenForLevel(DifficultyLevel difficulty) {
|
||||
return switch (difficulty) {
|
||||
DifficultyLevel.notSet => TimelineDifficultyIconToken.notSet,
|
||||
DifficultyLevel.veryEasy => TimelineDifficultyIconToken.veryEasy,
|
||||
DifficultyLevel.easy => TimelineDifficultyIconToken.easy,
|
||||
DifficultyLevel.medium => TimelineDifficultyIconToken.medium,
|
||||
DifficultyLevel.hard => TimelineDifficultyIconToken.hard,
|
||||
DifficultyLevel.veryHard => TimelineDifficultyIconToken.veryHard,
|
||||
};
|
||||
}
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ class _CardMetrics {
|
|||
|
||||
/// Returns the derived `actionsWidth` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
double get actionsWidth => actionButtonSize * 3 + actionGap;
|
||||
double get actionsWidth => actionButtonSize * 4 + actionGap;
|
||||
|
||||
/// Returns the derived `pushButtonHeight` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
|
|
|
|||
|
|
@ -124,3 +124,55 @@ class _QuickPushIcon extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Private implementation type for `_QuickBreakUpIcon` in this library.
|
||||
/// It exposes the same break-up dialog flow as the task selection modal from
|
||||
/// the timeline card's compact hover actions.
|
||||
class _QuickBreakUpIcon extends StatelessWidget {
|
||||
/// Creates a `_QuickBreakUpIcon` instance with the values required by its invariants.
|
||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||
const _QuickBreakUpIcon({
|
||||
required this.card,
|
||||
required this.color,
|
||||
required this.metrics,
|
||||
this.onBreakUp,
|
||||
});
|
||||
|
||||
/// Stores the `card` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final TimelineCardModel card;
|
||||
|
||||
/// Stores the `color` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final Color color;
|
||||
|
||||
/// Stores the `metrics` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final _CardMetrics metrics;
|
||||
|
||||
/// Callback invoked when Break up is selected.
|
||||
final Future<void> Function()? onBreakUp;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onBreakUp != null;
|
||||
return Tooltip(
|
||||
message: 'Break up',
|
||||
child: Semantics(
|
||||
button: true,
|
||||
enabled: enabled,
|
||||
label: 'Break up ${card.title}',
|
||||
child: GestureDetector(
|
||||
onTap: enabled ? () => unawaited(onBreakUp!()) : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox.square(
|
||||
dimension: metrics.actionButtonSize,
|
||||
child: Icon(Icons.apps, color: color, size: metrics.actionIconSize),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class _QuickActions extends StatelessWidget {
|
|||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
this.onBreakUp,
|
||||
});
|
||||
|
||||
/// Stores the `card` value for this object.
|
||||
|
|
@ -53,6 +54,9 @@ class _QuickActions extends StatelessWidget {
|
|||
/// Callback invoked when Push to backlog is selected.
|
||||
final Future<void> Function()? onPushToBacklog;
|
||||
|
||||
/// 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
|
||||
|
|
@ -90,6 +94,20 @@ class _QuickActions extends StatelessWidget {
|
|||
onPushToTomorrow: onPushToTomorrow,
|
||||
onPushToBacklog: onPushToBacklog,
|
||||
),
|
||||
if (onBreakUp != null)
|
||||
_QuickBreakUpIcon(
|
||||
card: card,
|
||||
color: color,
|
||||
metrics: metrics,
|
||||
onBreakUp: onBreakUp,
|
||||
)
|
||||
else
|
||||
_NoOpIcon(
|
||||
icon: Icons.apps,
|
||||
tooltip: 'Break up',
|
||||
color: color,
|
||||
metrics: metrics,
|
||||
),
|
||||
_NoOpIcon(
|
||||
icon: Icons.calendar_today,
|
||||
tooltip: 'Schedule',
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
|||
onPushToBacklog: widget.onPushToBacklog == null
|
||||
? null
|
||||
: () => _runPush(widget.onPushToBacklog!),
|
||||
onBreakUp: widget.onBreakUpCard == null
|
||||
? null
|
||||
: () => _handleBreakUp(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -111,6 +114,25 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Opens the break-up dialog and forwards drafted child tasks to
|
||||
/// [TaskTimelineCard.onBreakUpCard] when the user confirms with at least
|
||||
/// one task.
|
||||
Future<void> _handleBreakUp(BuildContext context) async {
|
||||
final drafts = await showDialog<List<ApplicationChildTaskDraft>>(
|
||||
context: context,
|
||||
builder: (_) => const BreakUpDialog(),
|
||||
);
|
||||
if (drafts == null || drafts.isEmpty) {
|
||||
return;
|
||||
}
|
||||
logger.debug(
|
||||
() =>
|
||||
'Card break-up requested. cardId=${widget.card.id} '
|
||||
'childCount=${drafts.length}',
|
||||
);
|
||||
await widget.onBreakUpCard!(widget.card, drafts);
|
||||
}
|
||||
|
||||
/// Runs the `_runPush` operation and completes after its asynchronous work finishes.
|
||||
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
|
||||
Future<void> _runPush(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class _TrailingControls extends StatelessWidget {
|
|||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
this.onBreakUp,
|
||||
});
|
||||
|
||||
/// Stores the `card` value for this object.
|
||||
|
|
@ -48,6 +49,9 @@ class _TrailingControls extends StatelessWidget {
|
|||
/// Callback invoked when Push to backlog is selected.
|
||||
final Future<void> Function()? onPushToBacklog;
|
||||
|
||||
/// 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
|
||||
|
|
@ -66,6 +70,7 @@ class _TrailingControls extends StatelessWidget {
|
|||
onPushToNext: onPushToNext,
|
||||
onPushToTomorrow: onPushToTomorrow,
|
||||
onPushToBacklog: onPushToBacklog,
|
||||
onBreakUp: onBreakUp,
|
||||
),
|
||||
if (card.showPastTaskPushButton) ...[
|
||||
_PastTaskPushButton(
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import 'dart:async';
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart' show logger;
|
||||
import 'package:scheduler_core/scheduler_core.dart'
|
||||
show ApplicationChildTaskDraft, logger;
|
||||
|
||||
import '../../models/today_screen_models.dart';
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
import '../dialogs/break_up_dialog.dart';
|
||||
import '../status_circle_button.dart';
|
||||
import 'difficulty_bars.dart';
|
||||
import 'reward_icon.dart';
|
||||
|
|
@ -37,6 +39,7 @@ class TaskTimelineCard extends StatefulWidget {
|
|||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
this.onBreakUpCard,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -58,6 +61,13 @@ class TaskTimelineCard extends StatefulWidget {
|
|||
/// Callback invoked when the card requests Push to backlog.
|
||||
final Future<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.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ library;
|
|||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart'
|
||||
show ApplicationChildTaskDraft;
|
||||
|
||||
import '../../models/today_screen_models.dart';
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
|
|
@ -29,6 +31,7 @@ class TimelineView extends StatefulWidget {
|
|||
this.onPushToNext,
|
||||
this.onPushToTomorrow,
|
||||
this.onPushToBacklog,
|
||||
this.onBreakUpCard,
|
||||
this.now,
|
||||
this.scrollTargetCardId,
|
||||
this.scrollRequest = 0,
|
||||
|
|
@ -60,6 +63,14 @@ class TimelineView extends StatefulWidget {
|
|||
/// Callback invoked when a task card requests Push to backlog.
|
||||
final Future<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.
|
||||
final DateTime Function()? now;
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ class _TimelineViewState extends State<TimelineView> {
|
|||
onPushToNext: widget.onPushToNext,
|
||||
onPushToTomorrow: widget.onPushToTomorrow,
|
||||
onPushToBacklog: widget.onPushToBacklog,
|
||||
onBreakUpCard: widget.onBreakUpCard,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class TopBar extends StatelessWidget {
|
|||
this.onNextDate,
|
||||
this.onDatePressed,
|
||||
this.onDateLabelPressed,
|
||||
this.onOpenSettings,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -40,6 +41,9 @@ class TopBar extends StatelessWidget {
|
|||
/// Callback invoked when the displayed date label is activated.
|
||||
final VoidCallback? onDateLabelPressed;
|
||||
|
||||
/// Callback invoked when the Settings button is activated.
|
||||
final VoidCallback? onOpenSettings;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
|
|
@ -130,7 +134,7 @@ class TopBar extends StatelessWidget {
|
|||
const Spacer(),
|
||||
_ModeToggle(metrics: metrics),
|
||||
SizedBox(width: metrics.settingsGap),
|
||||
_SettingsButton(metrics: metrics),
|
||||
_SettingsButton(metrics: metrics, onPressed: onOpenSettings),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,18 +8,22 @@ part of '../top_bar.dart';
|
|||
class _SettingsButton extends StatelessWidget {
|
||||
/// Creates a `_SettingsButton` instance with the values required by its invariants.
|
||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||
const _SettingsButton({required this.metrics});
|
||||
const _SettingsButton({required this.metrics, this.onPressed});
|
||||
|
||||
/// Stores the `metrics` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final _TopBarMetrics metrics;
|
||||
|
||||
/// Callback invoked when the button is activated.
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OutlinedButton(
|
||||
onPressed: () {},
|
||||
key: const ValueKey('top-bar-settings-button'),
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
side: BorderSide(
|
||||
|
|
|
|||
|
|
@ -77,10 +77,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: drift
|
||||
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
|
||||
sha256: c21b9af62e78f86837638dda6c33506bd9444ca8a32cad2b99c07369cf9e2462
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.34.0"
|
||||
version: "2.34.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
107
apps/focus_flow_flutter/test/app/demo_backlog_seed_test.dart
Normal file
107
apps/focus_flow_flutter/test/app/demo_backlog_seed_test.dart
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests Demo Backlog Seed behavior in the FocusFlow Flutter app.
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart';
|
||||
|
||||
/// Runs demo Backlog seed tests.
|
||||
void main() {
|
||||
test('seeded backlog board exposes mockup-scale domain data', () async {
|
||||
final composition = DemoSchedulerComposition.seeded(
|
||||
selectedDate: CivilDate(2025, 5, 20),
|
||||
);
|
||||
addTearDown(composition.dispose);
|
||||
|
||||
final board = (await composition.managementUseCases.getBacklogBoard(
|
||||
GetBacklogBoardRequest(
|
||||
context: composition.context('demo-backlog-board'),
|
||||
localDate: composition.date,
|
||||
sortKey: BacklogSortKey.age,
|
||||
),
|
||||
)).requireValue;
|
||||
final items = board.columns.expand((column) => column.items).toList();
|
||||
|
||||
expect(board.summary.totalTaskCount, 24);
|
||||
expect(board.summary.totalEstimatedMinutes, 735);
|
||||
expect(_distributionCounts(board.summary.projectDistribution), {
|
||||
'Home': 5,
|
||||
'Learning': 4,
|
||||
'Personal': 5,
|
||||
'Side Project': 2,
|
||||
'Work': 8,
|
||||
});
|
||||
expect(_distributionCounts(board.summary.priorityDistribution), {
|
||||
'High': 7,
|
||||
'Medium': 9,
|
||||
'Low': 8,
|
||||
});
|
||||
expect(items.map((item) => item.title), containsAll(_visibleSeedTitles));
|
||||
|
||||
final review = items.singleWhere(
|
||||
(item) => item.title == 'Review Q3 budget',
|
||||
);
|
||||
expect(review.projectName, 'Work');
|
||||
expect(review.durationMinutes, 60);
|
||||
expect(review.priority, PriorityLevel.high);
|
||||
expect(review.reward, RewardLevel.medium);
|
||||
expect(review.difficulty, DifficultyLevel.easy);
|
||||
});
|
||||
|
||||
test('seeded review detail stays within deferred metadata scope', () async {
|
||||
final composition = DemoSchedulerComposition.seeded(
|
||||
selectedDate: CivilDate(2025, 5, 20),
|
||||
);
|
||||
addTearDown(composition.dispose);
|
||||
|
||||
final detail = (await composition.managementUseCases.getBacklogTaskDetail(
|
||||
GetBacklogTaskDetailRequest(
|
||||
context: composition.context('demo-review-detail'),
|
||||
localDate: composition.date,
|
||||
taskId: 'review-q3-budget',
|
||||
),
|
||||
)).requireValue;
|
||||
|
||||
expect(detail.item.title, 'Review Q3 budget');
|
||||
expect(detail.notes, isNull);
|
||||
expect(detail.tags, isEmpty);
|
||||
expect(detail.suggestedSlots, isNotEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
/// Visible task titles from the Backlog board mockup that must be present in the seed.
|
||||
const _visibleSeedTitles = <String>[
|
||||
'Reply to 3 emails',
|
||||
'Update project status',
|
||||
'Schedule dentist appt',
|
||||
'Clean up downloads folder',
|
||||
'Plan tomorrow',
|
||||
'Review Q3 budget',
|
||||
'Plan next week',
|
||||
'Define weekly goals',
|
||||
'Time block schedule',
|
||||
'Organize kitchen drawers',
|
||||
'Build analytics dashboard',
|
||||
'Write marketing strategy',
|
||||
'Complete online course',
|
||||
'Module 1: Foundations',
|
||||
'Module 2: Deep Dive',
|
||||
'Module 3: Practice',
|
||||
'Learn guitar',
|
||||
'Start YouTube channel',
|
||||
'Travel to Japan',
|
||||
'Home office upgrade',
|
||||
'Read 2 books',
|
||||
];
|
||||
|
||||
/// Top-level helper that performs the `_distributionCounts` operation for this file.
|
||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||
Map<String, int> _distributionCounts(
|
||||
BacklogDistributionReadModel distribution,
|
||||
) {
|
||||
return {for (final entry in distribution.entries) entry.label: entry.count};
|
||||
}
|
||||
115
apps/focus_flow_flutter/test/app/settings_test.dart
Normal file
115
apps/focus_flow_flutter/test/app/settings_test.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests app-level settings dialog behavior.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart';
|
||||
import 'package:focus_flow_flutter/app/focus_flow_app.dart';
|
||||
|
||||
/// Runs app-level settings dialog tests.
|
||||
void main() {
|
||||
testWidgets('sidebar Settings opens a dialog seeded with persisted values', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('nav-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(find.text('UTC'), findsOneWidget);
|
||||
|
||||
final compactSwitch = tester.widget<SwitchListTile>(
|
||||
find.byKey(const ValueKey('settings-compact-mode')),
|
||||
);
|
||||
expect(compactSwitch.value, isTrue);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('top bar Settings button opens the same dialog', (tester) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('top-bar-settings-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('settings-timezone')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('saving settings persists the change and reopens with it', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('nav-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('settings-timezone')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('EST').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('settings-save')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('nav-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('EST'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('invalid staleness thresholds show a calm inline error', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('nav-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('settings-aging-days')),
|
||||
'1',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('settings-save')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(
|
||||
find.text('Aging must be a number of days at least as large as Fresh.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
|
||||
/// Pumps the FocusFlow app at the desktop test size.
|
||||
Future<void> _pumpPlanApp(WidgetTester tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1586, 992));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(FocusFlowApp(composition: _composition()));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Builds the seeded demo composition used by settings dialog tests.
|
||||
DemoSchedulerComposition _composition() {
|
||||
return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20));
|
||||
}
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests Backlog board controller behavior.
|
||||
library;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:focus_flow_flutter/controllers/backlog_board_controller.dart';
|
||||
import 'package:focus_flow_flutter/controllers/selected_date_controller.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
/// Runs Backlog board controller tests.
|
||||
void main() {
|
||||
group('BacklogBoardController', () {
|
||||
test('load reads selected date and exposes ready data', () async {
|
||||
BacklogBoardReadRequest? capturedRequest;
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
final controller = BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
readBoard: (request) async {
|
||||
capturedRequest = request;
|
||||
return ApplicationResult.success(_board(taskCount: 1));
|
||||
},
|
||||
readTaskDetail: _detailReader,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(capturedRequest!.localDate, CivilDate(2026, 7, 7));
|
||||
expect(capturedRequest!.sortKey, BacklogSortKey.custom);
|
||||
expect(capturedRequest!.boardFilters.isEmpty, isTrue);
|
||||
final state = controller.state;
|
||||
expect(state, isA<BacklogBoardReady>());
|
||||
expect((state as BacklogBoardReady).data.summary.totalTaskCount, 1);
|
||||
});
|
||||
|
||||
test('load maps empty and failure states', () async {
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
final controller = BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
readBoard: (_) async {
|
||||
return ApplicationResult.success(_board(taskCount: 0));
|
||||
},
|
||||
readTaskDetail: _detailReader,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.load();
|
||||
expect(controller.state, isA<BacklogBoardEmpty>());
|
||||
|
||||
final failing = BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
readBoard: (_) async {
|
||||
return ApplicationResult.failure(
|
||||
const ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
detailCode: 'readFailed',
|
||||
),
|
||||
);
|
||||
},
|
||||
readTaskDetail: _detailReader,
|
||||
);
|
||||
addTearDown(failing.dispose);
|
||||
|
||||
await failing.load();
|
||||
final state = failing.state;
|
||||
expect(state, isA<BacklogBoardFailure>());
|
||||
expect((state as BacklogBoardFailure).message, 'readFailed');
|
||||
});
|
||||
|
||||
test('selection loads detail without persisting selection', () async {
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
var detailReads = 0;
|
||||
final controller = BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
readBoard: (_) async {
|
||||
return ApplicationResult.success(_board(taskCount: 1));
|
||||
},
|
||||
readTaskDetail: (taskId, localDate) async {
|
||||
detailReads += 1;
|
||||
expect(taskId, 'task-1');
|
||||
expect(localDate, CivilDate(2026, 7, 7));
|
||||
return ApplicationResult.success(_detail());
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.load();
|
||||
await controller.selectTask('task-1');
|
||||
|
||||
final state = controller.state as BacklogBoardReady;
|
||||
expect(detailReads, 1);
|
||||
expect(state.data.selectedTaskId, 'task-1');
|
||||
|
||||
controller.clearSelection();
|
||||
|
||||
final cleared = controller.state as BacklogBoardReady;
|
||||
expect(cleared.data.selectedTaskId, isNull);
|
||||
});
|
||||
|
||||
test('detail failures stay local to the selected drawer', () async {
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
final controller = BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
readBoard: (_) async {
|
||||
return ApplicationResult.success(_board(taskCount: 1));
|
||||
},
|
||||
readTaskDetail: (taskId, localDate) async {
|
||||
return ApplicationResult.failure(
|
||||
const ApplicationFailure(
|
||||
code: ApplicationFailureCode.notFound,
|
||||
detailCode: 'taskNotFound',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.load();
|
||||
await controller.selectTask('task-1');
|
||||
|
||||
expect(controller.state, isA<BacklogBoardReady>());
|
||||
expect(controller.selectedTaskId, 'task-1');
|
||||
expect(controller.selectedTaskDetail, isNull);
|
||||
expect(controller.isSelectedTaskDetailLoading, isFalse);
|
||||
expect(controller.selectedTaskDetailError, 'taskNotFound');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Test detail reader that returns a stable detail model.
|
||||
Future<ApplicationResult<BacklogTaskDetailReadModel>> _detailReader(
|
||||
String taskId,
|
||||
CivilDate localDate,
|
||||
) async {
|
||||
return ApplicationResult.success(_detail());
|
||||
}
|
||||
|
||||
/// Builds a Backlog board query result with [taskCount].
|
||||
BacklogBoardQueryResult _board({required int taskCount}) {
|
||||
final items = [
|
||||
for (var index = 0; index < taskCount; index += 1)
|
||||
_item(id: 'task-${index + 1}'),
|
||||
];
|
||||
final totalEstimatedMinutes = items.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + (item.durationMinutes ?? 0),
|
||||
);
|
||||
return BacklogBoardQueryResult(
|
||||
ownerId: 'owner-1',
|
||||
localDate: CivilDate(2026, 7, 7),
|
||||
columns: [
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.doNext,
|
||||
title: 'Do Next',
|
||||
subtitle: 'Ready.',
|
||||
accentToken: 'green',
|
||||
items: items,
|
||||
),
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.needTimeBlock,
|
||||
title: 'Need Time Block',
|
||||
subtitle: 'Block.',
|
||||
accentToken: 'yellow',
|
||||
items: const [],
|
||||
),
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.breakUpFirst,
|
||||
title: 'Break Up First',
|
||||
subtitle: 'Split.',
|
||||
accentToken: 'purple',
|
||||
items: const [],
|
||||
),
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.notNow,
|
||||
title: 'Not Now',
|
||||
subtitle: 'Later.',
|
||||
accentToken: 'blue',
|
||||
items: const [],
|
||||
),
|
||||
],
|
||||
summary: BacklogBoardSummaryReadModel(
|
||||
totalTaskCount: taskCount,
|
||||
totalEstimatedMinutes: totalEstimatedMinutes,
|
||||
priorityDistribution: BacklogDistributionReadModel(
|
||||
title: 'Priority',
|
||||
entries: const [],
|
||||
),
|
||||
projectDistribution: BacklogDistributionReadModel(
|
||||
title: 'Project',
|
||||
entries: const [],
|
||||
),
|
||||
durationDistribution: BacklogDistributionReadModel(
|
||||
title: 'Duration',
|
||||
entries: const [],
|
||||
),
|
||||
planningTip: 'Pick one task.',
|
||||
),
|
||||
projectOptions: const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a Backlog board item for tests.
|
||||
BacklogBoardItemReadModel _item({required String id}) {
|
||||
return BacklogBoardItemReadModel(
|
||||
taskId: id,
|
||||
title: 'Task $id',
|
||||
projectId: 'home',
|
||||
projectName: 'Home',
|
||||
projectColorToken: 'project-home',
|
||||
durationMinutes: 30,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.low,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
bucket: BacklogBoardBucket.doNext,
|
||||
bucketReason: 'Ready.',
|
||||
createdAt: DateTime.utc(2026, 7, 7),
|
||||
updatedAt: DateTime.utc(2026, 7, 7),
|
||||
childPreview: const [],
|
||||
tags: const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a Backlog task detail model for tests.
|
||||
BacklogTaskDetailReadModel _detail() {
|
||||
return BacklogTaskDetailReadModel(
|
||||
item: _item(id: 'task-1'),
|
||||
addedLabel: 'Added today',
|
||||
tags: const [],
|
||||
projectOptions: const [],
|
||||
suggestedSlots: const [],
|
||||
);
|
||||
}
|
||||
|
|
@ -552,6 +552,7 @@ SchedulerCommandController _commandController(
|
|||
}) {
|
||||
return SchedulerCommandController(
|
||||
commands: composition.commandUseCases,
|
||||
management: composition.managementUseCases,
|
||||
contextFor: composition.context,
|
||||
selectedDate: () => selectedDate,
|
||||
refreshReads: () async {},
|
||||
|
|
|
|||
|
|
@ -41,6 +41,34 @@ void main() {
|
|||
expect(find.byKey(const ValueKey('nav-today-active')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('sidebar switches to Backlog shell', (tester) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
await tester.tap(find.text('Backlog').first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('nav-backlog-active')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('nav-today-active')), findsNothing);
|
||||
expect(
|
||||
find.text('Choose work that fits your current energy.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('backlog-search-field')), findsOneWidget);
|
||||
expect(find.text('Search backlog...'), findsOneWidget);
|
||||
expect(find.text('Filters'), findsOneWidget);
|
||||
expect(find.text('Sort: Custom'), findsOneWidget);
|
||||
expect(find.text('Group: None'), findsOneWidget);
|
||||
expect(find.text('Board'), findsOneWidget);
|
||||
expect(find.text('New Task'), findsOneWidget);
|
||||
expect(find.text('Backlog summary'), findsOneWidget);
|
||||
expect(find.text('24 tasks'), findsOneWidget);
|
||||
expect(find.text('Do Next'), findsOneWidget);
|
||||
expect(find.text('Need Time Block'), findsOneWidget);
|
||||
expect(find.text('Break Up First'), findsOneWidget);
|
||||
expect(find.text('Not Now'), findsOneWidget);
|
||||
expect(find.text('Review Q3 budget'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('seeded Today renders backend-backed compact mockup data', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -96,9 +124,9 @@ void main() {
|
|||
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
||||
});
|
||||
|
||||
testWidgets('task modal opens closes and keeps compact actions inert', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets(
|
||||
'task modal opens and closes, and Break up opens a cancellable dialog',
|
||||
(tester) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
||||
|
|
@ -107,7 +135,10 @@ void main() {
|
|||
await tester.tap(find.byKey(const ValueKey('pay-bill')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('task-selection-modal')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Pay bill'), findsNWidgets(2));
|
||||
expect(find.textContaining('Required'), findsWidgets);
|
||||
expect(find.byTooltip('Reward level 2'), findsOneWidget);
|
||||
|
|
@ -124,7 +155,16 @@ void main() {
|
|||
await tester.tap(find.text('Break up'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||
expect(find.text('Break up task'), 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));
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('close-task-modal')));
|
||||
|
|
@ -140,7 +180,8 @@ void main() {
|
|||
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
||||
expect(find.text('Pay bill'), findsOneWidget);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('task modal Push exposes destination labels and callbacks', (
|
||||
tester,
|
||||
|
|
@ -870,6 +911,51 @@ void main() {
|
|||
await gesture.removePointer();
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'hover Break up icon opens the same break-up dialog as the task modal',
|
||||
(tester) async {
|
||||
TimelineCardModel? brokenUpCard;
|
||||
List<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', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -1223,6 +1309,11 @@ Future<void> _pumpTimelineCard(
|
|||
Future<void> Function(TimelineCardModel card)? onPushToNext,
|
||||
Future<void> Function(TimelineCardModel card)? onPushToTomorrow,
|
||||
Future<void> Function(TimelineCardModel card)? onPushToBacklog,
|
||||
Future<void> Function(
|
||||
TimelineCardModel card,
|
||||
List<ApplicationChildTaskDraft> children,
|
||||
)?
|
||||
onBreakUpCard,
|
||||
}) async {
|
||||
await tester.binding.setSurfaceSize(
|
||||
Size(size.width + 120, size.height + 220),
|
||||
|
|
@ -1242,6 +1333,7 @@ Future<void> _pumpTimelineCard(
|
|||
onPushToNext: onPushToNext,
|
||||
onPushToTomorrow: onPushToTomorrow,
|
||||
onPushToBacklog: onPushToBacklog,
|
||||
onBreakUpCard: onBreakUpCard,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,999 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests Backlog board screen card rendering.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import 'package:focus_flow_flutter/controllers/backlog_board_controller.dart';
|
||||
import 'package:focus_flow_flutter/controllers/scheduler_command_controller.dart';
|
||||
import 'package:focus_flow_flutter/controllers/selected_date_controller.dart';
|
||||
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
|
||||
import 'package:focus_flow_flutter/theme/focus_flow_tokens.dart';
|
||||
import 'package:focus_flow_flutter/widgets/backlog/backlog_board_screen.dart';
|
||||
|
||||
/// Runs Backlog board widget tests.
|
||||
void main() {
|
||||
testWidgets('renders card metadata child previews and selected state', (
|
||||
tester,
|
||||
) async {
|
||||
final detailReads = <String>[];
|
||||
final item = _boardItemWithChildren();
|
||||
final board = _boardWithItem(item);
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
final controller = BacklogBoardController(
|
||||
readBoard: (_) async => ApplicationResult.success(board),
|
||||
readTaskDetail: (taskId, localDate) async {
|
||||
detailReads.add(taskId);
|
||||
return ApplicationResult.success(_detailFor(item));
|
||||
},
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(selectedDates.dispose);
|
||||
|
||||
await controller.load();
|
||||
await _pumpBacklogScreen(tester, controller);
|
||||
|
||||
expect(find.text('Do Next'), findsOneWidget);
|
||||
expect(find.text('Need Time Block'), findsOneWidget);
|
||||
expect(find.text('Break Up First'), findsOneWidget);
|
||||
expect(find.text('Not Now'), findsOneWidget);
|
||||
final card = find.byKey(const ValueKey('backlog-card-plan-week'));
|
||||
expect(card, findsOneWidget);
|
||||
expect(find.text('Plan next week'), findsOneWidget);
|
||||
expect(find.text('Outline priorities + tasks'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: card, matching: find.text('30 min')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: card, matching: find.text('Work')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: card, matching: find.text('High')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: card, matching: find.text('Difficulty')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: card, matching: find.text('Reward')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('backlog-child-define-weekly-goals')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Define weekly goals'), findsOneWidget);
|
||||
expect(find.text('15 min'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(detailReads, ['plan-week']);
|
||||
expect(controller.selectedTaskId, 'plan-week');
|
||||
final cardContainer = tester.widget<AnimatedContainer>(
|
||||
find.byKey(const ValueKey('backlog-card-container-plan-week')),
|
||||
);
|
||||
final decoration = cardContainer.decoration! as BoxDecoration;
|
||||
final border = decoration.border! as Border;
|
||||
expect(border.top.color, FocusFlowTokens.accentMagenta);
|
||||
});
|
||||
|
||||
testWidgets('drawer overlays board and close clears selected state', (
|
||||
tester,
|
||||
) async {
|
||||
final item = _boardItemWithChildren();
|
||||
final board = _boardWithItem(item);
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
final controller = BacklogBoardController(
|
||||
readBoard: (_) async => ApplicationResult.success(board),
|
||||
readTaskDetail: (taskId, localDate) async {
|
||||
return ApplicationResult.success(_detailFor(item));
|
||||
},
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(selectedDates.dispose);
|
||||
|
||||
await controller.load();
|
||||
await _pumpBacklogScreen(tester, controller);
|
||||
|
||||
final cardFinder = find.byKey(const ValueKey('backlog-card-plan-week'));
|
||||
final beforeOpen = tester.getTopLeft(cardFinder);
|
||||
|
||||
await tester.tap(cardFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final drawer = find.byKey(const ValueKey('backlog-task-drawer'));
|
||||
expect(drawer, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(of: drawer, matching: find.text('Plan next week')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.getTopLeft(cardFinder), beforeOpen);
|
||||
expect(controller.selectedTaskId, 'plan-week');
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-drawer-close-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(drawer, findsNothing);
|
||||
expect(controller.selectedTaskId, isNull);
|
||||
final cardContainer = tester.widget<AnimatedContainer>(
|
||||
find.byKey(const ValueKey('backlog-card-container-plan-week')),
|
||||
);
|
||||
final decoration = cardContainer.decoration! as BoxDecoration;
|
||||
final border = decoration.border! as Border;
|
||||
expect(border.top.color, isNot(FocusFlowTokens.accentMagenta));
|
||||
});
|
||||
|
||||
testWidgets('detail read failure stays inside drawer overlay', (
|
||||
tester,
|
||||
) async {
|
||||
final item = _boardItemWithChildren();
|
||||
final board = _boardWithItem(item);
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
final controller = BacklogBoardController(
|
||||
readBoard: (_) async => ApplicationResult.success(board),
|
||||
readTaskDetail: (taskId, localDate) async {
|
||||
return ApplicationResult.failure(
|
||||
const ApplicationFailure(
|
||||
code: ApplicationFailureCode.notFound,
|
||||
detailCode: 'taskNotFound',
|
||||
),
|
||||
);
|
||||
},
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(selectedDates.dispose);
|
||||
|
||||
await controller.load();
|
||||
await _pumpBacklogScreen(tester, controller);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final drawer = find.byKey(const ValueKey('backlog-task-drawer'));
|
||||
expect(drawer, findsOneWidget);
|
||||
expect(find.text('Could not load task details'), findsOneWidget);
|
||||
expect(find.text('taskNotFound'), findsOneWidget);
|
||||
expect(find.text('Could not load backlog'), findsNothing);
|
||||
expect(find.text('Plan next week'), findsOneWidget);
|
||||
expect(controller.selectedTaskId, 'plan-week');
|
||||
});
|
||||
|
||||
testWidgets('new task creates backlog item through command controller', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = _BacklogCommandHarness.empty();
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.boardController.load();
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
harness.boardController,
|
||||
commandController: harness.commandController,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-new-task-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('backlog-new-task-title-field')),
|
||||
'Draft outline',
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-new-task-create-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.commandController.state, isA<SchedulerCommandSuccess>());
|
||||
expect(find.text('Task captured'), findsOneWidget);
|
||||
expect(find.text('Draft outline'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('new task dialog validates an empty title without writing', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = _BacklogCommandHarness.empty();
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.boardController.load();
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
harness.boardController,
|
||||
commandController: harness.commandController,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-new-task-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-new-task-create-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Title is required.'), findsOneWidget);
|
||||
expect(harness.commandController.state, isA<SchedulerCommandIdle>());
|
||||
expect(harness.store.currentTasks, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('schedule action uses command controller and refreshes board', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = _BacklogCommandHarness.withTasks([
|
||||
_domainBacklogTask(
|
||||
id: 'plan-week',
|
||||
title: 'Plan next week',
|
||||
durationMinutes: 30,
|
||||
),
|
||||
]);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.boardController.load();
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
harness.boardController,
|
||||
commandController: harness.commandController,
|
||||
);
|
||||
|
||||
expect(find.text('Plan next week'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-card-plan-week')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-schedule-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.commandController.state, isA<SchedulerCommandSuccess>());
|
||||
expect(find.text('Task scheduled'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('backlog-task-drawer')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('backlog-card-plan-week')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('suggested slot action schedules the indicated time', (
|
||||
tester,
|
||||
) async {
|
||||
final existingFlexible = Task(
|
||||
id: 'existing-flexible',
|
||||
title: 'Existing flexible',
|
||||
projectId: 'home',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.planned,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
durationMinutes: 30,
|
||||
scheduledStart: _now,
|
||||
scheduledEnd: _now.add(const Duration(minutes: 30)),
|
||||
createdAt: _now,
|
||||
updatedAt: _now,
|
||||
);
|
||||
final harness = _BacklogCommandHarness.withTasks([
|
||||
_domainBacklogTask(
|
||||
id: 'plan-slot',
|
||||
title: 'Plan slot',
|
||||
durationMinutes: 30,
|
||||
),
|
||||
existingFlexible,
|
||||
]);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.boardController.load();
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
harness.boardController,
|
||||
commandController: harness.commandController,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-card-plan-slot')));
|
||||
await tester.pumpAndSettle();
|
||||
final suggestedSlot = find.byKey(
|
||||
const ValueKey('backlog-drawer-suggested-slot-plan-slot:primary'),
|
||||
);
|
||||
await tester.ensureVisible(suggestedSlot);
|
||||
await tester.tap(suggestedSlot);
|
||||
await _settleAsyncUi(tester);
|
||||
|
||||
expect(harness.commandController.state, isA<SchedulerCommandSuccess>());
|
||||
expect(find.text('Task scheduled'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('backlog-task-drawer')), findsNothing);
|
||||
final scheduled = _taskById(harness.store.currentTasks, 'plan-slot');
|
||||
final pushed = _taskById(harness.store.currentTasks, 'existing-flexible');
|
||||
expect(scheduled.status, TaskStatus.planned);
|
||||
expect(scheduled.scheduledStart, _now);
|
||||
expect(scheduled.scheduledEnd, _now.add(const Duration(minutes: 30)));
|
||||
expect(pushed.scheduledStart, _now.add(const Duration(minutes: 30)));
|
||||
expect(pushed.scheduledEnd, _now.add(const Duration(minutes: 60)));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'schedule action explains missing duration without command write',
|
||||
(tester) async {
|
||||
final harness = _BacklogCommandHarness.withTasks([
|
||||
_domainBacklogTask(
|
||||
id: 'missing-duration',
|
||||
title: 'Estimate me',
|
||||
durationMinutes: null,
|
||||
),
|
||||
]);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.boardController.load();
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
harness.boardController,
|
||||
commandController: harness.commandController,
|
||||
);
|
||||
|
||||
await harness.boardController.selectTask('missing-duration');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-schedule-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Add an estimate before scheduling.'), findsOneWidget);
|
||||
expect(harness.commandController.state, isA<SchedulerCommandIdle>());
|
||||
expect(find.byKey(const ValueKey('backlog-task-drawer')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('break up action creates child tasks and refreshes board', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = _BacklogCommandHarness.withTasks([
|
||||
_domainBacklogTask(
|
||||
id: 'outline-report',
|
||||
title: 'Outline report',
|
||||
durationMinutes: 30,
|
||||
),
|
||||
]);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.boardController.load();
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
harness.boardController,
|
||||
commandController: harness.commandController,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-card-outline-report')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-break-up-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Break up task'), findsOneWidget);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('backlog-break-up-title-field-0')),
|
||||
'Draft sections',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('backlog-break-up-duration-field-0')),
|
||||
'15',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('backlog-break-up-title-field-1')),
|
||||
'Review outline',
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-break-up-save-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.commandController.state, isA<SchedulerCommandSuccess>());
|
||||
expect(find.text('Task broken up'), findsOneWidget);
|
||||
expect(find.text('Draft sections'), findsWidgets);
|
||||
expect(find.text('Review outline'), findsWidgets);
|
||||
expect(
|
||||
harness.store.currentTasks.where((task) => task.parentTaskId != null),
|
||||
hasLength(2),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('break up dialog validates two child titles', (tester) async {
|
||||
final harness = _BacklogCommandHarness.withTasks([
|
||||
_domainBacklogTask(
|
||||
id: 'plan-event',
|
||||
title: 'Plan event',
|
||||
durationMinutes: 30,
|
||||
),
|
||||
]);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.boardController.load();
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
harness.boardController,
|
||||
commandController: harness.commandController,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-card-plan-event')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-break-up-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('backlog-break-up-title-field-0')),
|
||||
'Choose date',
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-break-up-save-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Add at least two titled steps.'), findsOneWidget);
|
||||
expect(harness.commandController.state, isA<SchedulerCommandIdle>());
|
||||
expect(
|
||||
harness.store.currentTasks.where((task) => task.parentTaskId != null),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('push to someday refreshes selected detail and board bucket', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = _BacklogCommandHarness.withTasks([
|
||||
_domainBacklogTask(
|
||||
id: 'maybe-later',
|
||||
title: 'Maybe later',
|
||||
durationMinutes: 30,
|
||||
),
|
||||
]);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.boardController.load();
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
harness.boardController,
|
||||
commandController: harness.commandController,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-card-maybe-later')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-push-someday-button')),
|
||||
);
|
||||
await _settleAsyncUi(tester);
|
||||
|
||||
expect(harness.commandController.state, isA<SchedulerCommandSuccess>());
|
||||
expect(find.text('Moved to Someday'), findsOneWidget);
|
||||
expect(
|
||||
_taskById(harness.store.currentTasks, 'maybe-later').backlogTags,
|
||||
contains(BacklogTag.wishlist),
|
||||
);
|
||||
expect(harness.boardController.selectedTaskDetail?.tags, ['someday']);
|
||||
final state = harness.boardController.state as BacklogBoardReady;
|
||||
final notNow = state.data.columns.singleWhere(
|
||||
(column) => column.bucket == BacklogBoardBucket.notNow,
|
||||
);
|
||||
expect(notNow.items.single.taskId, 'maybe-later');
|
||||
});
|
||||
|
||||
testWidgets('remove action can cancel then archive active backlog item', (
|
||||
tester,
|
||||
) async {
|
||||
final harness = _BacklogCommandHarness.withTasks([
|
||||
_domainBacklogTask(
|
||||
id: 'archive-me',
|
||||
title: 'Archive me',
|
||||
durationMinutes: 30,
|
||||
),
|
||||
]);
|
||||
addTearDown(harness.dispose);
|
||||
|
||||
await harness.boardController.load();
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
harness.boardController,
|
||||
commandController: harness.commandController,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-card-archive-me')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-remove-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Remove from Backlog?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.commandController.state, isA<SchedulerCommandIdle>());
|
||||
expect(
|
||||
_taskById(harness.store.currentTasks, 'archive-me').status,
|
||||
TaskStatus.backlog,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('backlog-task-drawer')), findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-remove-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-remove-confirm-button')),
|
||||
);
|
||||
await _settleAsyncUi(tester);
|
||||
|
||||
expect(harness.commandController.state, isA<SchedulerCommandSuccess>());
|
||||
expect(find.text('Removed from active Backlog.'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('backlog-task-drawer')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('backlog-card-archive-me')), findsNothing);
|
||||
expect(
|
||||
_taskById(harness.store.currentTasks, 'archive-me').status,
|
||||
TaskStatus.noLongerRelevant,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('controls update board read requests', (tester) async {
|
||||
final requests = <BacklogBoardReadRequest>[];
|
||||
final item = _boardItemWithChildren();
|
||||
final board = _boardWithItem(
|
||||
item,
|
||||
projectOptions: const [
|
||||
ProjectOptionReadModel(
|
||||
projectId: 'work',
|
||||
name: 'Work',
|
||||
colorToken: 'project-work',
|
||||
isArchived: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
final controller = BacklogBoardController(
|
||||
readBoard: (request) async {
|
||||
requests.add(request);
|
||||
return ApplicationResult.success(board);
|
||||
},
|
||||
readTaskDetail: (taskId, localDate) async {
|
||||
return ApplicationResult.success(_detailFor(item));
|
||||
},
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(selectedDates.dispose);
|
||||
|
||||
await controller.load();
|
||||
await _pumpBacklogScreen(tester, controller);
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('backlog-search-field')),
|
||||
'course',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(requests.last.searchText, 'course');
|
||||
expect(find.byKey(const ValueKey('backlog-search-clear')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-search-clear')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(requests.last.searchText, isNull);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-sort-menu-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Reward vs Effort').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(requests.last.sortKey, BacklogSortKey.rewardVsEffort);
|
||||
expect(find.text('Sort: Reward'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-group-menu-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Project').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(requests.last.groupMode, BacklogBoardGroupMode.project);
|
||||
expect(find.text('Group: Project'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-filter-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Future<void> tapFilterCheckbox(String label) async {
|
||||
final checkbox = find.widgetWithText(CheckboxListTile, label);
|
||||
await tester.ensureVisible(checkbox);
|
||||
await tester.tap(checkbox);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
await tapFilterCheckbox('High priority');
|
||||
await tapFilterCheckbox('Work');
|
||||
await tapFilterCheckbox('0-30 min');
|
||||
await tapFilterCheckbox('Need Time Block');
|
||||
await tapFilterCheckbox('No reward set');
|
||||
await tester.tap(find.text('Apply'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(requests.last.filters, [BacklogFilter.noRewardSet]);
|
||||
expect(
|
||||
requests.last.boardFilters.priorityGroups,
|
||||
contains(BacklogBoardPriorityFilter.high),
|
||||
);
|
||||
expect(requests.last.boardFilters.projectIds, contains('work'));
|
||||
expect(
|
||||
requests.last.boardFilters.durationBuckets,
|
||||
contains(BacklogBoardDurationFilter.zeroToThirty),
|
||||
);
|
||||
expect(
|
||||
requests.last.boardFilters.buckets,
|
||||
contains(BacklogBoardBucket.needTimeBlock),
|
||||
);
|
||||
expect(find.text('Filters 5'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-filter-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Clear'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(requests.last.filters, isEmpty);
|
||||
expect(requests.last.boardFilters.isEmpty, isTrue);
|
||||
expect(find.text('Filters'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('layout avoids overflow at review desktop widths', (
|
||||
tester,
|
||||
) async {
|
||||
final item = _boardItemWithChildren();
|
||||
final board = _boardWithItem(item);
|
||||
final selectedDates = SelectedDateController(
|
||||
initialDate: CivilDate(2026, 7, 7),
|
||||
);
|
||||
final controller = BacklogBoardController(
|
||||
readBoard: (_) async => ApplicationResult.success(board),
|
||||
readTaskDetail: (taskId, localDate) async {
|
||||
return ApplicationResult.success(_detailFor(item));
|
||||
},
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(selectedDates.dispose);
|
||||
|
||||
await controller.load();
|
||||
for (final surfaceSize in _reviewSurfaceSizes) {
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
controller,
|
||||
surfaceSize: surfaceSize,
|
||||
frameSize: Size(surfaceSize.width - 24, surfaceSize.height - 40),
|
||||
);
|
||||
expect(
|
||||
tester.takeException(),
|
||||
isNull,
|
||||
reason: 'Unexpected layout exception at $surfaceSize.',
|
||||
);
|
||||
}
|
||||
|
||||
await controller.selectTask('plan-week');
|
||||
await _pumpBacklogScreen(
|
||||
tester,
|
||||
controller,
|
||||
surfaceSize: const Size(1100, 760),
|
||||
frameSize: const Size(1076, 720),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('backlog-task-drawer')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
/// Pumps [controller] inside the dark FocusFlow app shell test harness.
|
||||
Future<void> _pumpBacklogScreen(
|
||||
WidgetTester tester,
|
||||
BacklogBoardController controller, {
|
||||
SchedulerCommandController? commandController,
|
||||
Size surfaceSize = const Size(1320, 820),
|
||||
Size frameSize = const Size(1300, 780),
|
||||
}) async {
|
||||
await tester.binding.setSurfaceSize(surfaceSize);
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: FocusFlowTheme.dark(),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: frameSize.width,
|
||||
height: frameSize.height,
|
||||
child: BacklogBoardScreen(
|
||||
controller: controller,
|
||||
commandController: commandController,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Desktop widths used for final Backlog layout smoke tests.
|
||||
const _reviewSurfaceSizes = [
|
||||
Size(1672, 940),
|
||||
Size(1440, 860),
|
||||
Size(1280, 820),
|
||||
Size(1100, 760),
|
||||
];
|
||||
|
||||
/// Lets unawaited UI command futures finish and repaint their final state.
|
||||
Future<void> _settleAsyncUi(WidgetTester tester) async {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.runAsync(() async {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Test harness that wires Backlog UI controllers to in-memory scheduler use cases.
|
||||
class _BacklogCommandHarness {
|
||||
/// Creates a harness from initial [tasks].
|
||||
_BacklogCommandHarness._({required List<Task> tasks})
|
||||
: selectedDates = SelectedDateController(initialDate: _testDate),
|
||||
store = InMemoryApplicationUnitOfWork(
|
||||
initialProjects: [
|
||||
ProjectProfile(id: 'home', name: 'Home', colorKey: 'home'),
|
||||
],
|
||||
initialOwnerSettings: [
|
||||
OwnerSettings(ownerId: _ownerId, timeZoneId: 'UTC'),
|
||||
],
|
||||
initialTasks: tasks,
|
||||
) {
|
||||
managementUseCases = V1ApplicationManagementUseCases(
|
||||
applicationStore: store,
|
||||
);
|
||||
commandUseCases = V1ApplicationCommandUseCases(
|
||||
applicationStore: store,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
);
|
||||
boardController = BacklogBoardController(
|
||||
selectedDates: selectedDates,
|
||||
dateLabelFor: (date) => date.toIsoString(),
|
||||
readBoard: (request) {
|
||||
return managementUseCases.getBacklogBoard(
|
||||
GetBacklogBoardRequest(
|
||||
context: _context('read-board'),
|
||||
localDate: request.localDate,
|
||||
searchText: request.searchText,
|
||||
filters: request.filters,
|
||||
boardFilters: request.boardFilters,
|
||||
sortKey: request.sortKey,
|
||||
groupMode: request.groupMode,
|
||||
),
|
||||
);
|
||||
},
|
||||
readTaskDetail: (taskId, localDate) {
|
||||
return managementUseCases.getBacklogTaskDetail(
|
||||
GetBacklogTaskDetailRequest(
|
||||
context: _context('read-detail-$taskId'),
|
||||
localDate: localDate,
|
||||
taskId: taskId,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
commandController = SchedulerCommandController(
|
||||
commands: commandUseCases,
|
||||
management: managementUseCases,
|
||||
contextFor: _context,
|
||||
selectedDate: () => selectedDates.date,
|
||||
refreshReads: boardController.load,
|
||||
quickCaptureProjectId: 'home',
|
||||
operationIdPrefix: 'backlog-widget-command',
|
||||
now: () => _now,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates an empty harness.
|
||||
factory _BacklogCommandHarness.empty() {
|
||||
return _BacklogCommandHarness._(tasks: const []);
|
||||
}
|
||||
|
||||
/// Creates a harness seeded with [tasks].
|
||||
factory _BacklogCommandHarness.withTasks(List<Task> tasks) {
|
||||
return _BacklogCommandHarness._(tasks: tasks);
|
||||
}
|
||||
|
||||
/// Selected date controller.
|
||||
final SelectedDateController selectedDates;
|
||||
|
||||
/// In-memory scheduler application store.
|
||||
final InMemoryApplicationUnitOfWork store;
|
||||
|
||||
/// Management use cases.
|
||||
late final V1ApplicationManagementUseCases managementUseCases;
|
||||
|
||||
/// Command use cases.
|
||||
late final V1ApplicationCommandUseCases commandUseCases;
|
||||
|
||||
/// Backlog board controller.
|
||||
late final BacklogBoardController boardController;
|
||||
|
||||
/// Scheduler command controller.
|
||||
late final SchedulerCommandController commandController;
|
||||
|
||||
/// Releases harness-owned controllers.
|
||||
void dispose() {
|
||||
commandController.dispose();
|
||||
boardController.dispose();
|
||||
selectedDates.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Owner id used by command integration widget tests.
|
||||
const _ownerId = 'owner-1';
|
||||
|
||||
/// Selected date used by command integration widget tests.
|
||||
final _testDate = CivilDate(2026, 7, 7);
|
||||
|
||||
/// Stable command clock used by command integration widget tests.
|
||||
final _now = DateTime.utc(2026, 7, 7, 15);
|
||||
|
||||
/// Creates an operation context for [operationId].
|
||||
ApplicationOperationContext _context(String operationId, {DateTime? now}) {
|
||||
return ApplicationOperationContext.start(
|
||||
operationId: operationId,
|
||||
ownerTimeZone: OwnerTimeZoneContext(ownerId: _ownerId, timeZoneId: 'UTC'),
|
||||
clock: FixedClock(now ?? _now),
|
||||
idGenerator: SequentialIdGenerator(prefix: operationId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a domain Backlog task for command integration widget tests.
|
||||
Task _domainBacklogTask({
|
||||
required String id,
|
||||
required String title,
|
||||
required int? durationMinutes,
|
||||
}) {
|
||||
return Task(
|
||||
id: id,
|
||||
title: title,
|
||||
projectId: 'home',
|
||||
type: TaskType.flexible,
|
||||
status: TaskStatus.backlog,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
durationMinutes: durationMinutes,
|
||||
createdAt: _now,
|
||||
updatedAt: _now,
|
||||
);
|
||||
}
|
||||
|
||||
/// Finds the task with [taskId] in [tasks].
|
||||
Task _taskById(List<Task> tasks, String taskId) {
|
||||
return tasks.singleWhere((task) => task.id == taskId);
|
||||
}
|
||||
|
||||
/// Builds a board item with child preview rows for widget tests.
|
||||
BacklogBoardItemReadModel _boardItemWithChildren() {
|
||||
return BacklogBoardItemReadModel(
|
||||
taskId: 'plan-week',
|
||||
title: 'Plan next week',
|
||||
subtitle: 'Outline priorities + tasks',
|
||||
projectId: 'work',
|
||||
projectName: 'Work',
|
||||
projectColorToken: 'project-work',
|
||||
durationMinutes: 30,
|
||||
priority: PriorityLevel.high,
|
||||
reward: RewardLevel.high,
|
||||
difficulty: DifficultyLevel.medium,
|
||||
bucket: BacklogBoardBucket.breakUpFirst,
|
||||
bucketReason: 'Already has child-task planning.',
|
||||
createdAt: DateTime.utc(2026, 7, 7),
|
||||
updatedAt: DateTime.utc(2026, 7, 7),
|
||||
childPreview: const [
|
||||
BacklogChildPreviewReadModel(
|
||||
taskId: 'define-weekly-goals',
|
||||
title: 'Define weekly goals',
|
||||
durationMinutes: 15,
|
||||
isCompleted: false,
|
||||
),
|
||||
],
|
||||
tags: const ['someday'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a Backlog board containing [item] and the other stable empty columns.
|
||||
BacklogBoardQueryResult _boardWithItem(
|
||||
BacklogBoardItemReadModel item, {
|
||||
List<ProjectOptionReadModel> projectOptions = const [],
|
||||
}) {
|
||||
return BacklogBoardQueryResult(
|
||||
ownerId: 'owner-1',
|
||||
localDate: CivilDate(2026, 7, 7),
|
||||
columns: [
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.doNext,
|
||||
title: 'Do Next',
|
||||
subtitle: 'Ready when you have a small opening.',
|
||||
accentToken: 'backlog-do-next',
|
||||
items: const [],
|
||||
),
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.needTimeBlock,
|
||||
title: 'Need Time Block',
|
||||
subtitle: 'Give these a protected block.',
|
||||
accentToken: 'backlog-time-block',
|
||||
items: const [],
|
||||
),
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.breakUpFirst,
|
||||
title: 'Break Up First',
|
||||
subtitle: 'Split these before scheduling.',
|
||||
accentToken: 'backlog-break-up',
|
||||
items: [item],
|
||||
),
|
||||
BacklogBoardColumnReadModel(
|
||||
bucket: BacklogBoardBucket.notNow,
|
||||
title: 'Not Now',
|
||||
subtitle: 'Keep these out of today pressure.',
|
||||
accentToken: 'backlog-not-now',
|
||||
items: const [],
|
||||
),
|
||||
],
|
||||
summary: BacklogBoardSummaryReadModel(
|
||||
totalTaskCount: 1,
|
||||
totalEstimatedMinutes: item.durationMinutes ?? 0,
|
||||
priorityDistribution: BacklogDistributionReadModel(
|
||||
title: 'By priority',
|
||||
entries: const [
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: 'high',
|
||||
label: 'High',
|
||||
count: 1,
|
||||
percentage: 1,
|
||||
colorToken: 'priority-high',
|
||||
),
|
||||
],
|
||||
),
|
||||
projectDistribution: BacklogDistributionReadModel(
|
||||
title: 'By project',
|
||||
entries: const [
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: 'work',
|
||||
label: 'Work',
|
||||
count: 1,
|
||||
percentage: 1,
|
||||
colorToken: 'project-work',
|
||||
),
|
||||
],
|
||||
),
|
||||
durationDistribution: BacklogDistributionReadModel(
|
||||
title: 'Duration distribution',
|
||||
entries: const [],
|
||||
),
|
||||
planningTip: 'Pick one task.',
|
||||
),
|
||||
projectOptions: projectOptions,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds selected-task detail for [item].
|
||||
BacklogTaskDetailReadModel _detailFor(BacklogBoardItemReadModel item) {
|
||||
return BacklogTaskDetailReadModel(
|
||||
item: item,
|
||||
addedLabel: 'Added today',
|
||||
tags: item.tags,
|
||||
projectOptions: const [],
|
||||
suggestedSlots: const [],
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests Backlog summary panel rendering and collapse behavior.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
|
||||
import 'package:focus_flow_flutter/widgets/backlog/backlog_summary_panel.dart';
|
||||
|
||||
/// Runs Backlog summary panel widget tests.
|
||||
void main() {
|
||||
testWidgets('renders summary sections and toggles collapse state', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpSummaryPanel(tester, _summary());
|
||||
|
||||
expect(find.text('Backlog summary'), findsOneWidget);
|
||||
expect(find.text('24'), findsOneWidget);
|
||||
expect(find.text('12h 15m'), findsOneWidget);
|
||||
expect(find.text('Total tasks'), findsOneWidget);
|
||||
expect(find.text('Total estimated time'), findsOneWidget);
|
||||
expect(find.text('By priority'), findsOneWidget);
|
||||
expect(find.text('High'), findsOneWidget);
|
||||
expect(find.text('Medium'), findsOneWidget);
|
||||
expect(find.text('Low'), findsOneWidget);
|
||||
expect(find.text('By project'), findsOneWidget);
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
expect(find.text('Work'), findsOneWidget);
|
||||
expect(find.text('Duration distribution'), findsOneWidget);
|
||||
expect(find.text('0-30 min'), findsOneWidget);
|
||||
expect(find.text('7 (29%)'), findsOneWidget);
|
||||
expect(find.text('30-60 min'), findsOneWidget);
|
||||
expect(find.text('9 (38%)'), findsOneWidget);
|
||||
expect(find.text('60-120 min'), findsOneWidget);
|
||||
expect(find.text('6 (25%)'), findsOneWidget);
|
||||
expect(find.text('120+ min'), findsOneWidget);
|
||||
expect(find.text('2 (8%)'), findsOneWidget);
|
||||
expect(find.text('Planning tip'), findsOneWidget);
|
||||
expect(find.text(_planningTip), findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-summary-collapse-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Backlog summary'), findsOneWidget);
|
||||
expect(find.text('By priority'), findsNothing);
|
||||
expect(find.text('Duration distribution'), findsNothing);
|
||||
expect(find.text(_planningTip), findsNothing);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-summary-collapse-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('By priority'), findsOneWidget);
|
||||
expect(find.text('Duration distribution'), findsOneWidget);
|
||||
expect(find.text(_planningTip), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
/// Planning guidance text used by the panel fixture.
|
||||
const _planningTip =
|
||||
'Start with the small high-priority work, then reserve a block for the longer items.';
|
||||
|
||||
/// Pumps [summary] in a fixed-width dark FocusFlow test harness.
|
||||
Future<void> _pumpSummaryPanel(
|
||||
WidgetTester tester,
|
||||
BacklogBoardSummaryReadModel summary,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(420, 760));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: FocusFlowTheme.dark(),
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
height: 720,
|
||||
child: BacklogSummaryPanel(summary: summary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Builds a representative summary read model for panel tests.
|
||||
BacklogBoardSummaryReadModel _summary() {
|
||||
return BacklogBoardSummaryReadModel(
|
||||
totalTaskCount: 24,
|
||||
totalEstimatedMinutes: 735,
|
||||
priorityDistribution: BacklogDistributionReadModel(
|
||||
title: 'Priority',
|
||||
entries: const [
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: 'high',
|
||||
label: 'High',
|
||||
count: 7,
|
||||
percentage: 0.29,
|
||||
colorToken: 'priority-high',
|
||||
),
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: 'medium',
|
||||
label: 'Medium',
|
||||
count: 9,
|
||||
percentage: 0.38,
|
||||
colorToken: 'priority-medium',
|
||||
),
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: 'low',
|
||||
label: 'Low',
|
||||
count: 8,
|
||||
percentage: 0.33,
|
||||
colorToken: 'priority-low',
|
||||
),
|
||||
],
|
||||
),
|
||||
projectDistribution: BacklogDistributionReadModel(
|
||||
title: 'Project',
|
||||
entries: const [
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
count: 10,
|
||||
percentage: 0.42,
|
||||
colorToken: 'project-home',
|
||||
),
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: 'work',
|
||||
label: 'Work',
|
||||
count: 8,
|
||||
percentage: 0.33,
|
||||
colorToken: 'project-work',
|
||||
),
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: 'personal',
|
||||
label: 'Personal',
|
||||
count: 6,
|
||||
percentage: 0.25,
|
||||
colorToken: 'project-personal',
|
||||
),
|
||||
],
|
||||
),
|
||||
durationDistribution: BacklogDistributionReadModel(
|
||||
title: 'Duration',
|
||||
entries: const [
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: '0-30',
|
||||
label: '0-30 min',
|
||||
count: 7,
|
||||
percentage: 0.29,
|
||||
),
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: '30-60',
|
||||
label: '30-60 min',
|
||||
count: 9,
|
||||
percentage: 0.38,
|
||||
),
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: '60-120',
|
||||
label: '60-120 min',
|
||||
count: 6,
|
||||
percentage: 0.25,
|
||||
),
|
||||
BacklogDistributionEntryReadModel(
|
||||
id: '120+',
|
||||
label: '120+ min',
|
||||
count: 2,
|
||||
percentage: 0.08,
|
||||
),
|
||||
],
|
||||
),
|
||||
planningTip: _planningTip,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests Backlog task detail drawer rendering and callbacks.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import 'package:focus_flow_flutter/theme/focus_flow_theme.dart';
|
||||
import 'package:focus_flow_flutter/widgets/backlog/backlog_task_drawer.dart';
|
||||
|
||||
/// Runs Backlog task drawer widget tests.
|
||||
void main() {
|
||||
testWidgets('renders loaded detail sections and calls action callbacks', (
|
||||
tester,
|
||||
) async {
|
||||
var closeCount = 0;
|
||||
var scheduleCount = 0;
|
||||
var breakUpCount = 0;
|
||||
var somedayCount = 0;
|
||||
var removeCount = 0;
|
||||
var addTagCount = 0;
|
||||
var viewDayCount = 0;
|
||||
var suggestedSlotId = '';
|
||||
|
||||
await _pumpDrawer(
|
||||
tester,
|
||||
BacklogTaskDrawer(
|
||||
selectedTaskId: 'review-q3-budget',
|
||||
detail: _detail(),
|
||||
loading: false,
|
||||
errorMessage: null,
|
||||
actionMessage: null,
|
||||
onClose: () {
|
||||
closeCount += 1;
|
||||
},
|
||||
onSchedule: () {
|
||||
scheduleCount += 1;
|
||||
},
|
||||
onBreakUp: () {
|
||||
breakUpCount += 1;
|
||||
},
|
||||
onPushToSomeday: () {
|
||||
somedayCount += 1;
|
||||
},
|
||||
onRemove: () {
|
||||
removeCount += 1;
|
||||
},
|
||||
onSuggestedSlotPressed: (slot) {
|
||||
suggestedSlotId = slot.id;
|
||||
},
|
||||
onProjectPressed: () {},
|
||||
onAddTagPressed: () {
|
||||
addTagCount += 1;
|
||||
},
|
||||
onViewDayPressed: () {
|
||||
viewDayCount += 1;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Review Q3 budget'), findsOneWidget);
|
||||
expect(find.text('Finance review and notes'), findsOneWidget);
|
||||
expect(find.text('Added 2h ago'), findsOneWidget);
|
||||
expect(find.text('Notes'), findsOneWidget);
|
||||
expect(find.text(_notes), findsOneWidget);
|
||||
expect(find.text('Project'), findsOneWidget);
|
||||
expect(find.text('Work'), findsOneWidget);
|
||||
expect(find.text('Tags'), findsOneWidget);
|
||||
expect(find.text('finance'), findsOneWidget);
|
||||
expect(find.text('review'), findsOneWidget);
|
||||
expect(find.text('Duration'), findsOneWidget);
|
||||
expect(find.text('60 min'), findsWidgets);
|
||||
expect(find.text('Reward'), findsOneWidget);
|
||||
expect(find.text('3/5'), findsOneWidget);
|
||||
expect(find.text('Satisfying'), findsOneWidget);
|
||||
expect(find.text('Difficulty'), findsOneWidget);
|
||||
expect(find.text('2/5'), findsOneWidget);
|
||||
expect(find.text('Easy'), findsOneWidget);
|
||||
expect(find.text('Suggested slots'), findsOneWidget);
|
||||
expect(find.text('Today'), findsNWidgets(2));
|
||||
expect(find.text('Tomorrow'), findsOneWidget);
|
||||
expect(find.text('2:30 PM - 3:30 PM | 60 min'), findsOneWidget);
|
||||
expect(find.text('Great Fit'), findsNWidgets(2));
|
||||
expect(find.text('Okay'), findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-view-day-button')),
|
||||
);
|
||||
final suggestedSlot = find.byKey(
|
||||
const ValueKey('backlog-drawer-suggested-slot-slot-1'),
|
||||
);
|
||||
await tester.ensureVisible(suggestedSlot);
|
||||
await tester.pump();
|
||||
await tester.tap(suggestedSlot);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-add-tag-button')),
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-schedule-button')),
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-break-up-button')),
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-push-someday-button')),
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('backlog-drawer-remove-button')),
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('backlog-drawer-close-button')));
|
||||
|
||||
expect(viewDayCount, 1);
|
||||
expect(suggestedSlotId, 'slot-1');
|
||||
expect(addTagCount, 1);
|
||||
expect(scheduleCount, 1);
|
||||
expect(breakUpCount, 1);
|
||||
expect(somedayCount, 1);
|
||||
expect(removeCount, 1);
|
||||
expect(closeCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('renders loading and error states without detail data', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpDrawer(
|
||||
tester,
|
||||
BacklogTaskDrawer(
|
||||
selectedTaskId: 'task-1',
|
||||
detail: null,
|
||||
loading: true,
|
||||
errorMessage: null,
|
||||
actionMessage: null,
|
||||
onClose: () {},
|
||||
onSchedule: () {},
|
||||
onBreakUp: () {},
|
||||
onPushToSomeday: () {},
|
||||
onRemove: () {},
|
||||
onSuggestedSlotPressed: (_) {},
|
||||
onProjectPressed: () {},
|
||||
onAddTagPressed: () {},
|
||||
onViewDayPressed: () {},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Loading task'), findsOneWidget);
|
||||
expect(find.text('Preparing task-1 details.'), findsOneWidget);
|
||||
|
||||
await _pumpDrawer(
|
||||
tester,
|
||||
BacklogTaskDrawer(
|
||||
selectedTaskId: 'task-1',
|
||||
detail: null,
|
||||
loading: false,
|
||||
errorMessage: 'taskNotFound',
|
||||
actionMessage: null,
|
||||
onClose: () {},
|
||||
onSchedule: () {},
|
||||
onBreakUp: () {},
|
||||
onPushToSomeday: () {},
|
||||
onRemove: () {},
|
||||
onSuggestedSlotPressed: (_) {},
|
||||
onProjectPressed: () {},
|
||||
onAddTagPressed: () {},
|
||||
onViewDayPressed: () {},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Could not load task details'), findsOneWidget);
|
||||
expect(find.text('taskNotFound'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
/// Notes fixture text used by the drawer test.
|
||||
const _notes =
|
||||
'Scan numbers and compare against plan. Note any variances and prepare talking points.';
|
||||
|
||||
/// Pumps [drawer] in a fixed-size dark FocusFlow test harness.
|
||||
Future<void> _pumpDrawer(WidgetTester tester, Widget drawer) async {
|
||||
await tester.binding.setSurfaceSize(const Size(520, 860));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: FocusFlowTheme.dark(),
|
||||
home: Scaffold(
|
||||
body: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: SizedBox(width: 466, height: 820, child: drawer),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// Builds a loaded drawer detail fixture.
|
||||
BacklogTaskDetailReadModel _detail() {
|
||||
return BacklogTaskDetailReadModel(
|
||||
item: BacklogBoardItemReadModel(
|
||||
taskId: 'review-q3-budget',
|
||||
title: 'Review Q3 budget',
|
||||
subtitle: 'Finance review and notes',
|
||||
projectId: 'work',
|
||||
projectName: 'Work',
|
||||
projectColorToken: 'project-work',
|
||||
durationMinutes: 60,
|
||||
priority: PriorityLevel.high,
|
||||
reward: RewardLevel.medium,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
bucket: BacklogBoardBucket.needTimeBlock,
|
||||
bucketReason: 'Give this a protected block.',
|
||||
createdAt: DateTime.utc(2026, 7, 7, 19),
|
||||
updatedAt: DateTime.utc(2026, 7, 7, 19),
|
||||
childPreview: const [],
|
||||
tags: const ['finance', 'review'],
|
||||
),
|
||||
notes: _notes,
|
||||
addedLabel: 'Added 2h ago',
|
||||
tags: const ['finance', 'review'],
|
||||
projectOptions: const [
|
||||
ProjectOptionReadModel(
|
||||
projectId: 'work',
|
||||
name: 'Work',
|
||||
colorToken: 'project-work',
|
||||
isArchived: false,
|
||||
),
|
||||
],
|
||||
suggestedSlots: [
|
||||
SuggestedSlotReadModel(
|
||||
id: 'slot-1',
|
||||
startUtc: DateTime.utc(2026, 7, 7, 14, 30),
|
||||
endUtc: DateTime.utc(2026, 7, 7, 15, 30),
|
||||
localDateLabel: 'Today',
|
||||
startText: '2:30 PM',
|
||||
endText: '3:30 PM',
|
||||
durationMinutes: 60,
|
||||
fitLabel: 'Great Fit',
|
||||
fitSeverityToken: 'fit-great',
|
||||
isPrimary: true,
|
||||
),
|
||||
SuggestedSlotReadModel(
|
||||
id: 'slot-2',
|
||||
startUtc: DateTime.utc(2026, 7, 7, 18),
|
||||
endUtc: DateTime.utc(2026, 7, 7, 19),
|
||||
localDateLabel: 'Today',
|
||||
startText: '6:00 PM',
|
||||
endText: '7:00 PM',
|
||||
durationMinutes: 60,
|
||||
fitLabel: 'Okay',
|
||||
fitSeverityToken: 'fit-okay',
|
||||
isPrimary: false,
|
||||
),
|
||||
SuggestedSlotReadModel(
|
||||
id: 'slot-3',
|
||||
startUtc: DateTime.utc(2026, 7, 8, 10),
|
||||
endUtc: DateTime.utc(2026, 7, 8, 11),
|
||||
localDateLabel: 'Tomorrow',
|
||||
startText: '10:00 AM',
|
||||
endText: '11:00 AM',
|
||||
durationMinutes: 60,
|
||||
fitLabel: 'Great Fit',
|
||||
fitSeverityToken: 'fit-great',
|
||||
isPrimary: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# V1 ADR 002: SQLite Document Schema V1
|
||||
|
||||
Status: **Accepted**
|
||||
Date: 2026-06-26
|
||||
|
||||
## Context
|
||||
The project pivoted from MongoDB to **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.
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# V1 ADR 005: Export & Backup Boundary
|
||||
|
||||
Status: **Accepted**
|
||||
Date: 2026-06-26
|
||||
|
||||
* **Backup** – 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.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# V1 ADR 006: Schedule Snapshot Policy
|
||||
|
||||
Status: **Accepted**
|
||||
Date: 2026-06-26
|
||||
|
||||
Persist committed diagnostic snapshots only; bounded retention 30 days or until notices acknowledged.
|
||||
|
||||
No open questions remain.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# V1 ADR 008: Notification Abstraction
|
||||
|
||||
Status: **Accepted**
|
||||
Date: 2026-06-26
|
||||
|
||||
`NotificationAdapter` interface abstracts scheduling/cancellation and feedback stream. Desktop implementation in `scheduler_notifications_desktop`; Fake adapter for tests.
|
||||
|
||||
No open questions remain.
|
||||
|
|
@ -17,6 +17,10 @@ enum ApplicationCommandCode {
|
|||
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||
scheduleBacklogItemToNextAvailableSlot,
|
||||
|
||||
/// Selects the `scheduleBacklogItemAtSuggestedSlot` option from this enum.
|
||||
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||
scheduleBacklogItemAtSuggestedSlot,
|
||||
|
||||
/// Selects the `pushFlexibleToNextAvailableSlot` option from this enum.
|
||||
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||
pushFlexibleToNextAvailableSlot,
|
||||
|
|
@ -29,6 +33,14 @@ enum ApplicationCommandCode {
|
|||
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||
moveFlexibleToBacklog,
|
||||
|
||||
/// Selects the `pushBacklogTaskToSomeday` option from this enum.
|
||||
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||
pushBacklogTaskToSomeday,
|
||||
|
||||
/// Selects the `archiveBacklogTask` option from this enum.
|
||||
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||
archiveBacklogTask,
|
||||
|
||||
/// Selects the `removeTask` option from this enum.
|
||||
/// Code should use this named value when it needs this exact scheduler state, presentation token, or persistence meaning.
|
||||
removeTask,
|
||||
|
|
|
|||
|
|
@ -229,6 +229,10 @@ List<String> _changedTaskIds(List<Task> beforeTasks, List<Task> afterTasks) {
|
|||
|
||||
/// Top-level helper that performs the `_taskChanged` operation for this file.
|
||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||
///
|
||||
/// The comparison must include scheduler-visible metadata, not only scheduling
|
||||
/// placement fields, because application commands persist only the tasks this
|
||||
/// helper reports as changed.
|
||||
bool _taskChanged(Task before, Task after) {
|
||||
return before.title != after.title ||
|
||||
before.projectId != after.projectId ||
|
||||
|
|
@ -243,11 +247,22 @@ bool _taskChanged(Task before, Task after) {
|
|||
!_sameNullableInstant(before.actualStart, after.actualStart) ||
|
||||
!_sameNullableInstant(before.actualEnd, after.actualEnd) ||
|
||||
!_sameNullableInstant(before.completedAt, after.completedAt) ||
|
||||
!_sameNullableInstant(before.backlogEnteredAt, after.backlogEnteredAt) ||
|
||||
before.backlogEnteredAtProvenance != after.backlogEnteredAtProvenance ||
|
||||
before.parentTaskId != after.parentTaskId ||
|
||||
!_sameBacklogTags(before.backlogTags, after.backlogTags) ||
|
||||
before.reminderOverride != after.reminderOverride ||
|
||||
!_sameNullableInstant(before.createdAt, after.createdAt) ||
|
||||
!_sameNullableInstant(before.updatedAt, after.updatedAt) ||
|
||||
before.stats != after.stats;
|
||||
}
|
||||
|
||||
/// Top-level helper that performs the `_sameBacklogTags` operation for this file.
|
||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||
bool _sameBacklogTags(Set<BacklogTag> before, Set<BacklogTag> after) {
|
||||
return before.length == after.length && before.containsAll(after);
|
||||
}
|
||||
|
||||
/// Top-level helper that performs the `_sameNullableInstant` operation for this file.
|
||||
/// Keeping the helper private makes the surrounding flow easier to read while preventing unrelated libraries from depending on implementation detail.
|
||||
bool _sameNullableInstant(DateTime? first, DateTime? second) {
|
||||
|
|
|
|||
|
|
@ -285,6 +285,88 @@ class V1ApplicationCommandUseCases {
|
|||
);
|
||||
}
|
||||
|
||||
/// Schedule an existing backlog item into a selected suggested slot.
|
||||
Future<ApplicationResult<ApplicationCommandResult>>
|
||||
scheduleBacklogItemAtSuggestedSlot({
|
||||
required ApplicationOperationContext context,
|
||||
required CivilDate localDate,
|
||||
required String taskId,
|
||||
required DateTime scheduledStart,
|
||||
required DateTime scheduledEnd,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) {
|
||||
const commandCode =
|
||||
ApplicationCommandCode.scheduleBacklogItemAtSuggestedSlot;
|
||||
return applicationStore.run<ApplicationCommandResult>(
|
||||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
logger.debug(() =>
|
||||
'Application command executing. command=${commandCode.name} '
|
||||
'operationId=${context.operationId} localDate=${localDate.toIsoString()} '
|
||||
'taskId=$taskId scheduledStart=${scheduledStart.toIso8601String()} '
|
||||
'scheduledEnd=${scheduledEnd.toIso8601String()}');
|
||||
var state = await _loadPlanningState(repositories, context, localDate);
|
||||
var task = _taskById(state.tasks, taskId);
|
||||
if (task == null) {
|
||||
logger.finer(() =>
|
||||
'Planning-state task lookup missed; reading by id. '
|
||||
'command=${commandCode.name} taskId=$taskId operationId=${context.operationId}');
|
||||
task = await repositories.tasks.findById(taskId);
|
||||
if (task != null) {
|
||||
state = state.withTask(task);
|
||||
}
|
||||
}
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
final staleFailure = _staleFailure(task, expectedUpdatedAt);
|
||||
if (staleFailure != null) {
|
||||
return ApplicationResult.failure(staleFailure);
|
||||
}
|
||||
|
||||
final schedulingResult =
|
||||
const SchedulingEngine().insertBacklogTaskAtRequestedSlot(
|
||||
input: state.input,
|
||||
taskId: taskId,
|
||||
requestedStart: scheduledStart.toUtc(),
|
||||
requestedEnd: scheduledEnd.toUtc(),
|
||||
updatedAt: context.now,
|
||||
);
|
||||
final schedulingFailure = _failureForSchedulingResult(
|
||||
schedulingResult,
|
||||
entityId: taskId,
|
||||
);
|
||||
if (schedulingFailure != null) {
|
||||
return ApplicationResult.failure(schedulingFailure);
|
||||
}
|
||||
|
||||
final activities = _activitiesForSchedulingChanges(
|
||||
result: schedulingResult,
|
||||
beforeTasks: state.tasks,
|
||||
operationId: context.operationId,
|
||||
occurredAt: context.now,
|
||||
selectedTaskId: taskId,
|
||||
selectedActivityCode: TaskActivityCode.restoredFromBacklog,
|
||||
movedActivityCode: TaskActivityCode.automaticallyPushed,
|
||||
);
|
||||
return _persist(
|
||||
repositories: repositories,
|
||||
commandCode: commandCode,
|
||||
context: context,
|
||||
beforeTasks: state.tasks,
|
||||
afterTasks: schedulingResult.tasks,
|
||||
activities: activities,
|
||||
schedulingResult: schedulingResult,
|
||||
readHint: ApplicationCommandReadHint(
|
||||
localDate: localDate,
|
||||
affectedTaskIds: _taskIdsFromChanges(schedulingResult.changes),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Push a planned flexible task later in the same local day.
|
||||
Future<ApplicationResult<ApplicationCommandResult>>
|
||||
pushFlexibleToNextAvailableSlot({
|
||||
|
|
@ -375,6 +457,106 @@ class V1ApplicationCommandUseCases {
|
|||
);
|
||||
}
|
||||
|
||||
/// Add the Someday/Wishlist backlog tag to an active Backlog task.
|
||||
Future<ApplicationResult<ApplicationCommandResult>> pushBacklogTaskToSomeday({
|
||||
required ApplicationOperationContext context,
|
||||
required String taskId,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) {
|
||||
const commandCode = ApplicationCommandCode.pushBacklogTaskToSomeday;
|
||||
return applicationStore.run<ApplicationCommandResult>(
|
||||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
logger.debug(
|
||||
() => 'Application command executing. command=${commandCode.name} '
|
||||
'operationId=${context.operationId} taskId=$taskId');
|
||||
final task = await repositories.tasks.findById(taskId);
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
final staleFailure = _staleFailure(task, expectedUpdatedAt);
|
||||
if (staleFailure != null) {
|
||||
return ApplicationResult.failure(staleFailure);
|
||||
}
|
||||
if (task.status != TaskStatus.backlog) {
|
||||
return _failure(
|
||||
ApplicationFailureCode.conflict,
|
||||
entityId: taskId,
|
||||
detailCode: 'notBacklogTask',
|
||||
);
|
||||
}
|
||||
final updatedTags = {...task.backlogTags, BacklogTag.wishlist};
|
||||
final updated = task.copyWith(
|
||||
backlogTags: updatedTags,
|
||||
updatedAt: context.now,
|
||||
);
|
||||
return _persist(
|
||||
repositories: repositories,
|
||||
commandCode: commandCode,
|
||||
context: context,
|
||||
beforeTasks: [task],
|
||||
afterTasks: [updated],
|
||||
activities: const <TaskActivity>[],
|
||||
readHint: ApplicationCommandReadHint(
|
||||
affectedTaskIds: [taskId],
|
||||
refreshToday: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Hide an active Backlog task without deleting its persisted record.
|
||||
Future<ApplicationResult<ApplicationCommandResult>> archiveBacklogTask({
|
||||
required ApplicationOperationContext context,
|
||||
required String taskId,
|
||||
DateTime? expectedUpdatedAt,
|
||||
}) {
|
||||
const commandCode = ApplicationCommandCode.archiveBacklogTask;
|
||||
return applicationStore.run<ApplicationCommandResult>(
|
||||
context: context,
|
||||
operationName: commandCode.name,
|
||||
action: (repositories) async {
|
||||
logger.debug(
|
||||
() => 'Application command executing. command=${commandCode.name} '
|
||||
'operationId=${context.operationId} taskId=$taskId');
|
||||
final task = await repositories.tasks.findById(taskId);
|
||||
if (task == null) {
|
||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||
}
|
||||
final staleFailure = _staleFailure(task, expectedUpdatedAt);
|
||||
if (staleFailure != null) {
|
||||
return ApplicationResult.failure(staleFailure);
|
||||
}
|
||||
if (task.status != TaskStatus.backlog) {
|
||||
return _failure(
|
||||
ApplicationFailureCode.conflict,
|
||||
entityId: taskId,
|
||||
detailCode: 'notBacklogTask',
|
||||
);
|
||||
}
|
||||
final archived = task.copyWith(
|
||||
status: TaskStatus.noLongerRelevant,
|
||||
updatedAt: context.now,
|
||||
clearSchedule: true,
|
||||
);
|
||||
return _persist(
|
||||
repositories: repositories,
|
||||
commandCode: commandCode,
|
||||
context: context,
|
||||
beforeTasks: [task],
|
||||
afterTasks: [archived],
|
||||
activities: const <TaskActivity>[],
|
||||
readHint: ApplicationCommandReadHint(
|
||||
affectedTaskIds: [taskId],
|
||||
refreshToday: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Permanently remove a task from persistence.
|
||||
Future<ApplicationResult<ApplicationCommandResult>> removeTask({
|
||||
required ApplicationOperationContext context,
|
||||
|
|
|
|||
|
|
@ -18,17 +18,34 @@ import '../domain/models.dart';
|
|||
import '../domain/project_statistics.dart';
|
||||
import '../domain/time_contracts.dart';
|
||||
import '../logging/focus_flow_logger.dart';
|
||||
import '../persistence/repositories.dart';
|
||||
import '../scheduling/scheduling_engine.dart';
|
||||
part 'application_management/codes/application_management_command_code.dart';
|
||||
part 'application_management/codes/owner_settings_warning_code.dart';
|
||||
part 'application_management/requests/backlog_board_filter_selection.dart';
|
||||
part 'application_management/requests/backlog_board_group_mode.dart';
|
||||
part 'application_management/requests/get_backlog_board_request.dart';
|
||||
part 'application_management/requests/get_backlog_request.dart';
|
||||
part 'application_management/requests/get_backlog_task_detail_request.dart';
|
||||
part 'application_management/read_models/backlog_item_read_model.dart';
|
||||
part 'application_management/read_models/backlog_query_result.dart';
|
||||
part 'application_management/read_models/backlog_board/backlog_board_column_read_model.dart';
|
||||
part 'application_management/read_models/backlog_board/backlog_board_item_read_model.dart';
|
||||
part 'application_management/read_models/backlog_board/backlog_board_query_result.dart';
|
||||
part 'application_management/read_models/backlog_board/backlog_board_summary_read_model.dart';
|
||||
part 'application_management/read_models/backlog_board/backlog_child_preview_read_model.dart';
|
||||
part 'application_management/read_models/backlog_board/backlog_distribution_entry_read_model.dart';
|
||||
part 'application_management/read_models/backlog_board/backlog_distribution_read_model.dart';
|
||||
part 'application_management/read_models/backlog_board/backlog_task_detail_read_model.dart';
|
||||
part 'application_management/read_models/backlog_board/project_option_read_model.dart';
|
||||
part 'application_management/read_models/backlog_board/suggested_slot_read_model.dart';
|
||||
part 'application_management/requests/get_project_defaults_request.dart';
|
||||
part 'application_management/read_models/project_defaults_query_result.dart';
|
||||
part 'application_management/drafts/project_profile_draft.dart';
|
||||
part 'application_management/updates/project_profile_update.dart';
|
||||
part 'application_management/drafts/locked_block_draft.dart';
|
||||
part 'application_management/updates/locked_block_update.dart';
|
||||
part 'application_management/requests/get_owner_settings_request.dart';
|
||||
part 'application_management/results/owner_settings_update_result.dart';
|
||||
part 'application_management/updates/owner_settings_update.dart';
|
||||
part 'application_management/results/notice_acknowledgement_result.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../application_management.dart';
|
||||
|
||||
/// One column in the Backlog board.
|
||||
class BacklogBoardColumnReadModel {
|
||||
/// Creates a board column with immutable [items].
|
||||
BacklogBoardColumnReadModel({
|
||||
required this.bucket,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.accentToken,
|
||||
required List<BacklogBoardItemReadModel> items,
|
||||
int? count,
|
||||
}) : items = List<BacklogBoardItemReadModel>.unmodifiable(items),
|
||||
count = count ?? items.length;
|
||||
|
||||
/// Bucket represented by this column.
|
||||
final BacklogBoardBucket bucket;
|
||||
|
||||
/// Display title for the column header.
|
||||
final String title;
|
||||
|
||||
/// Short display subtitle for the column header.
|
||||
final String subtitle;
|
||||
|
||||
/// UI color token used by Flutter to style the column accent.
|
||||
final String accentToken;
|
||||
|
||||
/// Count shown in the column header.
|
||||
final int count;
|
||||
|
||||
/// Items rendered in this column.
|
||||
final List<BacklogBoardItemReadModel> items;
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../application_management.dart';
|
||||
|
||||
/// Card-level read model for one Backlog board task.
|
||||
class BacklogBoardItemReadModel {
|
||||
/// Creates a Backlog board item from display-ready task metadata.
|
||||
BacklogBoardItemReadModel({
|
||||
required this.taskId,
|
||||
required this.title,
|
||||
required this.projectId,
|
||||
required this.projectName,
|
||||
required this.projectColorToken,
|
||||
required this.reward,
|
||||
required this.difficulty,
|
||||
required this.bucket,
|
||||
required this.bucketReason,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required List<BacklogChildPreviewReadModel> childPreview,
|
||||
required List<String> tags,
|
||||
this.subtitle,
|
||||
this.durationMinutes,
|
||||
this.priority,
|
||||
bool? hasChildren,
|
||||
}) : childPreview =
|
||||
List<BacklogChildPreviewReadModel>.unmodifiable(childPreview),
|
||||
tags = List<String>.unmodifiable(tags),
|
||||
hasChildren = hasChildren ?? childPreview.isNotEmpty;
|
||||
|
||||
/// Stable task id used for selection and commands.
|
||||
final String taskId;
|
||||
|
||||
/// User-facing task title.
|
||||
final String title;
|
||||
|
||||
/// Optional subtitle or notes preview for card display.
|
||||
final String? subtitle;
|
||||
|
||||
/// Owning project id.
|
||||
final String projectId;
|
||||
|
||||
/// Display name for the owning project.
|
||||
final String projectName;
|
||||
|
||||
/// UI color token for the owning project.
|
||||
final String projectColorToken;
|
||||
|
||||
/// Estimated task duration in minutes, if captured.
|
||||
final int? durationMinutes;
|
||||
|
||||
/// Optional task priority.
|
||||
final PriorityLevel? priority;
|
||||
|
||||
/// Expected reward or payoff.
|
||||
final RewardLevel reward;
|
||||
|
||||
/// Expected activation difficulty.
|
||||
final DifficultyLevel difficulty;
|
||||
|
||||
/// Board bucket selected by scheduler policy.
|
||||
final BacklogBoardBucket bucket;
|
||||
|
||||
/// User-facing reason for [bucket].
|
||||
final String bucketReason;
|
||||
|
||||
/// Original task creation timestamp.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Last task update timestamp.
|
||||
final DateTime updatedAt;
|
||||
|
||||
/// Whether the task has child task metadata.
|
||||
final bool hasChildren;
|
||||
|
||||
/// Preview rows for direct child tasks.
|
||||
final List<BacklogChildPreviewReadModel> childPreview;
|
||||
|
||||
/// Display-ready task-local tag labels.
|
||||
final List<String> tags;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../application_management.dart';
|
||||
|
||||
/// Query result for the Backlog board surface.
|
||||
class BacklogBoardQueryResult {
|
||||
/// Creates a Backlog board result from summary, columns, and project options.
|
||||
BacklogBoardQueryResult({
|
||||
required this.ownerId,
|
||||
required this.localDate,
|
||||
required this.summary,
|
||||
required List<BacklogBoardColumnReadModel> columns,
|
||||
required List<ProjectOptionReadModel> projectOptions,
|
||||
}) : columns = List<BacklogBoardColumnReadModel>.unmodifiable(columns),
|
||||
projectOptions = List<ProjectOptionReadModel>.unmodifiable(
|
||||
projectOptions,
|
||||
);
|
||||
|
||||
/// Authorization-neutral owner scope used for the read.
|
||||
final String ownerId;
|
||||
|
||||
/// Owner-local planning date used for suggested slot previews.
|
||||
final CivilDate localDate;
|
||||
|
||||
/// Summary counts and planning copy derived from the same filtered item set.
|
||||
final BacklogBoardSummaryReadModel summary;
|
||||
|
||||
/// Stable ordered board columns.
|
||||
final List<BacklogBoardColumnReadModel> columns;
|
||||
|
||||
/// Project choices available to board and drawer controls.
|
||||
final List<ProjectOptionReadModel> projectOptions;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../application_management.dart';
|
||||
|
||||
/// Summary panel read model for the Backlog board.
|
||||
class BacklogBoardSummaryReadModel {
|
||||
/// Creates a Backlog summary from count, estimate, and distribution data.
|
||||
const BacklogBoardSummaryReadModel({
|
||||
required this.totalTaskCount,
|
||||
required this.totalEstimatedMinutes,
|
||||
required this.priorityDistribution,
|
||||
required this.projectDistribution,
|
||||
required this.durationDistribution,
|
||||
required this.planningTip,
|
||||
});
|
||||
|
||||
/// Total number of tasks represented by the filtered board.
|
||||
final int totalTaskCount;
|
||||
|
||||
/// Sum of captured duration estimates across the filtered board.
|
||||
final int totalEstimatedMinutes;
|
||||
|
||||
/// Count distribution by priority label.
|
||||
final BacklogDistributionReadModel priorityDistribution;
|
||||
|
||||
/// Count distribution by project label.
|
||||
final BacklogDistributionReadModel projectDistribution;
|
||||
|
||||
/// Count and percentage distribution by duration bucket.
|
||||
final BacklogDistributionReadModel durationDistribution;
|
||||
|
||||
/// Display copy for the planning tip panel.
|
||||
final String planningTip;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../application_management.dart';
|
||||
|
||||
/// Compact preview for a direct child task.
|
||||
class BacklogChildPreviewReadModel {
|
||||
/// Creates a child task preview row.
|
||||
const BacklogChildPreviewReadModel({
|
||||
required this.taskId,
|
||||
required this.title,
|
||||
required this.isCompleted,
|
||||
this.durationMinutes,
|
||||
this.statusLabel,
|
||||
});
|
||||
|
||||
/// Stable child task id.
|
||||
final String taskId;
|
||||
|
||||
/// User-facing child task title.
|
||||
final String title;
|
||||
|
||||
/// Estimated child task duration in minutes, if captured.
|
||||
final int? durationMinutes;
|
||||
|
||||
/// Whether the child task is completed.
|
||||
final bool isCompleted;
|
||||
|
||||
/// Optional display label for the child task lifecycle state.
|
||||
final String? statusLabel;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../application_management.dart';
|
||||
|
||||
/// One entry in a Backlog summary distribution.
|
||||
class BacklogDistributionEntryReadModel {
|
||||
/// Creates a distribution entry.
|
||||
const BacklogDistributionEntryReadModel({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.percentage,
|
||||
this.colorToken,
|
||||
});
|
||||
|
||||
/// Stable entry id for tests and UI keys.
|
||||
final String id;
|
||||
|
||||
/// Display label for the entry.
|
||||
final String label;
|
||||
|
||||
/// Number of tasks in this entry.
|
||||
final int count;
|
||||
|
||||
/// Share of the parent distribution as a value from 0.0 to 1.0.
|
||||
final double percentage;
|
||||
|
||||
/// Optional UI color token for the entry.
|
||||
final String? colorToken;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../application_management.dart';
|
||||
|
||||
/// Named distribution used by Backlog summary panels.
|
||||
class BacklogDistributionReadModel {
|
||||
/// Creates a distribution with immutable [entries].
|
||||
BacklogDistributionReadModel({
|
||||
required this.title,
|
||||
required List<BacklogDistributionEntryReadModel> entries,
|
||||
int? totalCount,
|
||||
}) : entries = List<BacklogDistributionEntryReadModel>.unmodifiable(
|
||||
entries,
|
||||
),
|
||||
totalCount = totalCount ??
|
||||
entries.fold<int>(0, (sum, entry) => sum + entry.count);
|
||||
|
||||
/// Display title for this distribution.
|
||||
final String title;
|
||||
|
||||
/// Total item count represented by [entries].
|
||||
final int totalCount;
|
||||
|
||||
/// Ordered display entries.
|
||||
final List<BacklogDistributionEntryReadModel> entries;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../application_management.dart';
|
||||
|
||||
/// Drawer-level read model for a selected Backlog task.
|
||||
class BacklogTaskDetailReadModel {
|
||||
/// Creates a task detail result for the Backlog drawer.
|
||||
BacklogTaskDetailReadModel({
|
||||
required this.item,
|
||||
required this.addedLabel,
|
||||
required List<String> tags,
|
||||
required List<ProjectOptionReadModel> projectOptions,
|
||||
required List<SuggestedSlotReadModel> suggestedSlots,
|
||||
this.notes,
|
||||
this.suggestedSlotUnavailableReason,
|
||||
}) : tags = List<String>.unmodifiable(tags),
|
||||
projectOptions = List<ProjectOptionReadModel>.unmodifiable(
|
||||
projectOptions,
|
||||
),
|
||||
suggestedSlots = List<SuggestedSlotReadModel>.unmodifiable(
|
||||
suggestedSlots,
|
||||
);
|
||||
|
||||
/// Board card model for the selected task.
|
||||
final BacklogBoardItemReadModel item;
|
||||
|
||||
/// Optional long-form notes for the drawer.
|
||||
final String? notes;
|
||||
|
||||
/// Display label describing when the task was added to Backlog.
|
||||
final String addedLabel;
|
||||
|
||||
/// Display-ready task-local tag labels.
|
||||
final List<String> tags;
|
||||
|
||||
/// Available project choices for the selected task.
|
||||
final List<ProjectOptionReadModel> projectOptions;
|
||||
|
||||
/// Non-mutating suggested schedule slots.
|
||||
final List<SuggestedSlotReadModel> suggestedSlots;
|
||||
|
||||
/// Display reason when slot suggestions are unavailable.
|
||||
final String? suggestedSlotUnavailableReason;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../application_management.dart';
|
||||
|
||||
/// Project option exposed to Backlog board and drawer controls.
|
||||
class ProjectOptionReadModel {
|
||||
/// Creates a project option read model.
|
||||
const ProjectOptionReadModel({
|
||||
required this.projectId,
|
||||
required this.name,
|
||||
required this.colorToken,
|
||||
this.isArchived = false,
|
||||
});
|
||||
|
||||
/// Stable project id.
|
||||
final String projectId;
|
||||
|
||||
/// User-facing project name.
|
||||
final String name;
|
||||
|
||||
/// UI color token for the project.
|
||||
final String colorToken;
|
||||
|
||||
/// Whether this project is archived.
|
||||
final bool isArchived;
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../application_management.dart';
|
||||
|
||||
/// Preview row for a possible Backlog scheduling slot.
|
||||
class SuggestedSlotReadModel {
|
||||
/// Creates a suggested slot preview.
|
||||
const SuggestedSlotReadModel({
|
||||
required this.id,
|
||||
required this.startUtc,
|
||||
required this.endUtc,
|
||||
required this.localDateLabel,
|
||||
required this.startText,
|
||||
required this.endText,
|
||||
required this.durationMinutes,
|
||||
required this.fitLabel,
|
||||
required this.fitSeverityToken,
|
||||
required this.isPrimary,
|
||||
});
|
||||
|
||||
/// Stable suggestion id.
|
||||
final String id;
|
||||
|
||||
/// UTC instant where the suggested slot starts.
|
||||
final DateTime startUtc;
|
||||
|
||||
/// UTC instant where the suggested slot ends.
|
||||
final DateTime endUtc;
|
||||
|
||||
/// Display label for the owner-local date.
|
||||
final String localDateLabel;
|
||||
|
||||
/// Display-ready start time.
|
||||
final String startText;
|
||||
|
||||
/// Display-ready end time.
|
||||
final String endText;
|
||||
|
||||
/// Suggested slot length in minutes.
|
||||
final int durationMinutes;
|
||||
|
||||
/// Display label such as `Great Fit` or `Okay`.
|
||||
final String fitLabel;
|
||||
|
||||
/// UI severity token for the fit label.
|
||||
final String fitSeverityToken;
|
||||
|
||||
/// Whether this is the primary/default scheduling target.
|
||||
final bool isPrimary;
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../application_management.dart';
|
||||
|
||||
/// Priority groups exposed by the Backlog board filter UI.
|
||||
enum BacklogBoardPriorityFilter {
|
||||
/// High and very high priority tasks.
|
||||
high,
|
||||
|
||||
/// Medium priority tasks.
|
||||
medium,
|
||||
|
||||
/// Low and very low priority tasks.
|
||||
low,
|
||||
}
|
||||
|
||||
/// Duration buckets exposed by the Backlog board filter UI.
|
||||
enum BacklogBoardDurationFilter {
|
||||
/// Tasks estimated at thirty minutes or less.
|
||||
zeroToThirty,
|
||||
|
||||
/// Tasks estimated above thirty minutes and up to sixty minutes.
|
||||
thirtyToSixty,
|
||||
|
||||
/// Tasks estimated above sixty minutes and up to one hundred twenty minutes.
|
||||
sixtyToOneTwenty,
|
||||
|
||||
/// Tasks estimated above one hundred twenty minutes.
|
||||
overOneTwenty,
|
||||
}
|
||||
|
||||
/// Board-specific filters that do not belong to the legacy list filter enum.
|
||||
class BacklogBoardFilterSelection {
|
||||
/// Creates a board filter selection from user-facing filter groups.
|
||||
BacklogBoardFilterSelection({
|
||||
Iterable<BacklogBoardPriorityFilter> priorityGroups =
|
||||
const <BacklogBoardPriorityFilter>[],
|
||||
Iterable<String> projectIds = const <String>[],
|
||||
Iterable<BacklogBoardDurationFilter> durationBuckets =
|
||||
const <BacklogBoardDurationFilter>[],
|
||||
Iterable<BacklogBoardBucket> buckets = const <BacklogBoardBucket>[],
|
||||
this.missingDurationOnly = false,
|
||||
}) : priorityGroups = Set<BacklogBoardPriorityFilter>.unmodifiable(
|
||||
priorityGroups,
|
||||
),
|
||||
projectIds = Set<String>.unmodifiable(projectIds),
|
||||
durationBuckets = Set<BacklogBoardDurationFilter>.unmodifiable(
|
||||
durationBuckets,
|
||||
),
|
||||
buckets = Set<BacklogBoardBucket>.unmodifiable(buckets);
|
||||
|
||||
/// Creates an empty board filter selection.
|
||||
const BacklogBoardFilterSelection.empty()
|
||||
: priorityGroups = const <BacklogBoardPriorityFilter>{},
|
||||
projectIds = const <String>{},
|
||||
durationBuckets = const <BacklogBoardDurationFilter>{},
|
||||
buckets = const <BacklogBoardBucket>{},
|
||||
missingDurationOnly = false;
|
||||
|
||||
/// Selected priority groups.
|
||||
final Set<BacklogBoardPriorityFilter> priorityGroups;
|
||||
|
||||
/// Selected project ids.
|
||||
final Set<String> projectIds;
|
||||
|
||||
/// Selected duration buckets.
|
||||
final Set<BacklogBoardDurationFilter> durationBuckets;
|
||||
|
||||
/// Selected board buckets.
|
||||
final Set<BacklogBoardBucket> buckets;
|
||||
|
||||
/// Whether only tasks without a duration estimate should be returned.
|
||||
final bool missingDurationOnly;
|
||||
|
||||
/// Whether this selection contains no active board filters.
|
||||
bool get isEmpty {
|
||||
return priorityGroups.isEmpty &&
|
||||
projectIds.isEmpty &&
|
||||
durationBuckets.isEmpty &&
|
||||
buckets.isEmpty &&
|
||||
!missingDurationOnly;
|
||||
}
|
||||
|
||||
/// Number of active filter groups.
|
||||
int get activeFilterCount {
|
||||
return priorityGroups.length +
|
||||
projectIds.length +
|
||||
durationBuckets.length +
|
||||
buckets.length +
|
||||
(missingDurationOnly ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../application_management.dart';
|
||||
|
||||
/// Optional grouping mode requested by the Backlog board UI.
|
||||
enum BacklogBoardGroupMode {
|
||||
/// No additional grouping beyond the canonical board buckets.
|
||||
none,
|
||||
|
||||
/// Preserve board buckets while allowing project-aware ordering inside them.
|
||||
project,
|
||||
|
||||
/// Preserve board buckets while ordering by priority groups inside them.
|
||||
priority,
|
||||
|
||||
/// Preserve board buckets while ordering by duration groups inside them.
|
||||
duration,
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../application_management.dart';
|
||||
|
||||
/// Request for the Backlog board projection.
|
||||
class GetBacklogBoardRequest {
|
||||
/// Creates a board request with optional search, filters, sorting, and grouping.
|
||||
GetBacklogBoardRequest({
|
||||
required this.context,
|
||||
required this.localDate,
|
||||
this.searchText,
|
||||
List<BacklogFilter> filters = const <BacklogFilter>[],
|
||||
BacklogBoardFilterSelection? boardFilters,
|
||||
this.sortKey = BacklogSortKey.custom,
|
||||
this.groupMode = BacklogBoardGroupMode.none,
|
||||
}) : filters = List<BacklogFilter>.unmodifiable(filters),
|
||||
boardFilters =
|
||||
boardFilters ?? const BacklogBoardFilterSelection.empty();
|
||||
|
||||
/// Read context containing owner scope and read instant.
|
||||
final ApplicationOperationContext context;
|
||||
|
||||
/// Owner-local date used for schedule preview context.
|
||||
final CivilDate localDate;
|
||||
|
||||
/// Optional user-entered search text.
|
||||
final String? searchText;
|
||||
|
||||
/// Optional user-facing backlog filters.
|
||||
final List<BacklogFilter> filters;
|
||||
|
||||
/// Board-specific filters applied after bucket and card metadata resolution.
|
||||
final BacklogBoardFilterSelection boardFilters;
|
||||
|
||||
/// User-facing sort order used inside each board bucket.
|
||||
final BacklogSortKey sortKey;
|
||||
|
||||
/// Optional grouping mode requested by the UI.
|
||||
final BacklogBoardGroupMode groupMode;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../application_management.dart';
|
||||
|
||||
/// Request for one Backlog task drawer projection.
|
||||
class GetBacklogTaskDetailRequest {
|
||||
/// Creates a detail request for [taskId].
|
||||
const GetBacklogTaskDetailRequest({
|
||||
required this.context,
|
||||
required this.localDate,
|
||||
required this.taskId,
|
||||
});
|
||||
|
||||
/// Read context containing owner scope and read instant.
|
||||
final ApplicationOperationContext context;
|
||||
|
||||
/// Owner-local date used for schedule preview context.
|
||||
final CivilDate localDate;
|
||||
|
||||
/// Selected backlog task id.
|
||||
final String taskId;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../application_management.dart';
|
||||
|
||||
/// Owner settings query request.
|
||||
class GetOwnerSettingsRequest {
|
||||
/// Creates a `GetOwnerSettingsRequest` instance with the values required by its invariants.
|
||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||
const GetOwnerSettingsRequest({required this.context});
|
||||
|
||||
/// Read context containing owner scope and read instant.
|
||||
final ApplicationOperationContext context;
|
||||
}
|
||||
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