From b0d73f7489d306b1beb0ea3a7300f847078af584 Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Mon, 29 Jun 2026 20:20:24 -0700 Subject: [PATCH] feat(ui): complete compact today plan --- .../README.md | 2 +- ...PLAN_1_BLOCK_02_APP_SHELL_VISUAL_SYSTEM.md | 19 +- ...N_1_BLOCK_03_BACKEND_SEEDED_TODAY_STATE.md | 18 +- ..._PLAN_1_BLOCK_04_COMPACT_TIMELINE_CARDS.md | 23 +- ...UI_PLAN_1_BLOCK_05_TASK_SELECTION_MODAL.md | 18 +- ...N_1_BLOCK_06_TESTING_VALIDATION_HANDOFF.md | 46 ++- .../UI_PLAN_1_EXECUTION_ORDER.md | 2 +- .../UI_PLAN_1_SUMMARY.md | 2 +- apps/focus_flow_flutter/README.md | 38 ++- apps/focus_flow_flutter/lib/app.dart | 98 +----- .../lib/app/demo_scheduler_composition.dart | 306 +++++++++++++++++- .../lib/app/focus_flow_app.dart | 168 +++++++++- .../demo_scheduler_composition.dart | 197 +---------- .../controllers/today_screen_controller.dart | 81 ++++- .../lib/models/today_screen_models.dart | 207 ++++++++++++ .../lib/theme/focus_flow_theme.dart | 28 ++ .../lib/theme/focus_flow_tokens.dart | 34 +- .../lib/widgets/app_shell.dart | 36 ++- .../lib/widgets/required_banner.dart | 75 ++++- .../lib/widgets/sidebar.dart | 159 ++++++++- .../lib/widgets/task_selection_modal.dart | 188 ++++++++++- .../lib/widgets/timeline/difficulty_bars.dart | 78 ++++- .../lib/widgets/timeline/reward_icon.dart | 68 +++- .../widgets/timeline/task_timeline_card.dart | 222 ++++++++++++- .../lib/widgets/timeline/timeline_axis.dart | 88 ++++- .../widgets/timeline/timeline_geometry.dart | 53 ++- .../lib/widgets/timeline/timeline_view.dart | 62 +++- .../lib/widgets/top_bar.dart | 98 +++++- apps/focus_flow_flutter/test/widget_test.dart | 238 ++++++-------- 29 files changed, 2159 insertions(+), 493 deletions(-) diff --git a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/README.md b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/README.md index 1ce6a05..bedc6e1 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/README.md +++ b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/README.md @@ -1,6 +1,6 @@ # UI Plan 1 - Compact Today Timeline Mockup -**Status:** Planned +**Status:** Complete on 2026-06-30. Start with `UI_PLAN_1_SUMMARY.md`, then execute the block files in `UI_PLAN_1_EXECUTION_ORDER.md` order. diff --git a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_02_APP_SHELL_VISUAL_SYSTEM.md b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_02_APP_SHELL_VISUAL_SYSTEM.md index ebc1ec2..ab597e5 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_02_APP_SHELL_VISUAL_SYSTEM.md +++ b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_02_APP_SHELL_VISUAL_SYSTEM.md @@ -1,6 +1,6 @@ # UI Plan 1 Block 02 — App Shell and Visual System -**Status:** Planned +**Status:** Complete on 2026-06-30. **Goal:** Build the dark desktop shell and static visual system that matches the compact mockup frame. --- @@ -251,3 +251,20 @@ flutter test ``` At this point tests should cover shell/sidebar/top bar/banner presence. + +--- + +## Completed implementation + +1. Added `FocusFlowTokens` and `FocusFlowTheme` for the dark visual system. +2. Replaced the provisional app surface with a desktop shell that has a sidebar, + top bar, segmented Compact/Normal control, Settings button, required banner, + and Today content area. +3. Kept all shell controls enabled-looking and no-op. +4. Preserved backend package boundaries. + +## Verification + +1. `dart format lib test`: passed. +2. `flutter analyze`: passed, no issues found. +3. `flutter test`: passed. diff --git a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_03_BACKEND_SEEDED_TODAY_STATE.md b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_03_BACKEND_SEEDED_TODAY_STATE.md index 9dbc8f3..2011a98 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_03_BACKEND_SEEDED_TODAY_STATE.md +++ b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_03_BACKEND_SEEDED_TODAY_STATE.md @@ -1,6 +1,6 @@ # UI Plan 1 Block 03 — Backend-Backed Static Today Seed -**Status:** Planned +**Status:** Complete on 2026-06-30. **Goal:** Seed the current day through scheduler domain/application state and query it for the UI. --- @@ -225,3 +225,19 @@ flutter test ``` Tests should verify seeded query and UI adapter behavior before timeline polish begins. + +--- + +## Completed implementation + +1. Rebuilt `DemoSchedulerComposition` as the static UI Plan 1 seed boundary. +2. Seeded `ProjectProfile`, compact `OwnerSettings`, and six scheduled task + records through public scheduler domain objects. +3. Queried `GetTodayStateQuery` through an in-memory application store. +4. Added `TodayScreenController` loading/data/empty/failure state. +5. Added `TodayScreenData` and `TimelineCardModel` as thin UI display adapters. + +## Verification + +1. `flutter analyze`: passed, no issues found. +2. `flutter test`: passed, including seeded Today and visual mapping coverage. diff --git a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_04_COMPACT_TIMELINE_CARDS.md b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_04_COMPACT_TIMELINE_CARDS.md index f5be16d..a56a243 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_04_COMPACT_TIMELINE_CARDS.md +++ b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_04_COMPACT_TIMELINE_CARDS.md @@ -1,6 +1,6 @@ # UI Plan 1 Block 04 — Compact Timeline Rail and Task Cards -**Status:** Planned +**Status:** Complete on 2026-06-30. **Goal:** Render the seeded Today timeline so it visually matches the compact mockup. --- @@ -243,3 +243,24 @@ flutter test ``` Add/update widget tests for card labels and difficulty fill counts. + +--- + +## Completed implementation + +1. Added timeline geometry, axis, grid, and compact card layout. +2. Rendered all six seeded timeline rows from `TodayScreenData`. +3. Added solid custom reward sparkle rendering. +4. Added five-bar difficulty rendering with fill-count mapping. +5. Added card visual variants for flexible, required, appointment, free slot, + and completed surprise rows. + +## Verification + +1. `flutter analyze`: passed, no issues found. +2. `flutter test`: passed, including seeded labels and difficulty fill-count + coverage. + +Visual note: no separate screenshot artifact was checked in; widget tests render +the screen at the mockup-sized 1586x992 surface and assert the required +structure/labels without golden-file brittleness. diff --git a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_05_TASK_SELECTION_MODAL.md b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_05_TASK_SELECTION_MODAL.md index 6398bf3..b0b7511 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_05_TASK_SELECTION_MODAL.md +++ b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_05_TASK_SELECTION_MODAL.md @@ -1,6 +1,6 @@ # UI Plan 1 Block 05 — Task Selection Modal -**Status:** Planned +**Status:** Complete on 2026-06-30. **Goal:** Implement the only functional interaction for this slice: clicking a task opens the selected-task modal, and clicking outside/close dismisses it. --- @@ -171,3 +171,19 @@ cd apps/focus_flow_flutter flutter analyze flutter test ``` + +--- + +## Completed implementation + +1. Added UI-local selected card state to `TodayScreenController`. +2. Implemented centered selected-task modal with header, metadata tiles, four + no-op action buttons, close button, and footer copy. +3. Added click-away and close-button dismissal. +4. Kept modal action buttons enabled-looking and inert. + +## Verification + +1. `flutter analyze`: passed, no issues found. +2. `flutter test`: passed, including modal open, close, outside-click, and + no-op action behavior. diff --git a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_06_TESTING_VALIDATION_HANDOFF.md b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_06_TESTING_VALIDATION_HANDOFF.md index cda332c..d3d1513 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_06_TESTING_VALIDATION_HANDOFF.md +++ b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_BLOCK_06_TESTING_VALIDATION_HANDOFF.md @@ -1,6 +1,6 @@ # UI Plan 1 Block 06 — Testing, Validation, and Handoff -**Status:** Planned +**Status:** Complete on 2026-06-30. **Goal:** Harden UI Plan 1 with tests, validation commands, and handoff notes for the next functional component plans. --- @@ -181,3 +181,47 @@ BREAKPOINT: Stop at this final audit and report results before starting any func - Documentation captures scope and next steps. - No unrelated functionality was pulled in. - The zip/repo is ready for the next plan: functional component wiring. + +--- + +## Completed implementation + +1. Added widget/unit coverage for shell labels, seeded Today state, visual kind + mapping, difficulty fill counts, modal behavior, and forbidden imports. +2. Updated `apps/focus_flow_flutter/README.md` with scope, run/test commands, + seed-data location, backend-state ownership, no-op controls, and next-plan + notes. +3. Kept golden/snapshot testing out of CI; mockup comparison remains manual to + avoid brittle platform font pixel gates. + +## Final audit + +- [x] App launches into compact Today screen. +- [x] Source mockups are copied into this plan folder. +- [x] Clean visual state follows `main_screen_compact.png` structure. +- [x] Selected modal state follows `task_selected_modal.png` structure. +- [x] No white app background. +- [x] Buttons look enabled but are no-op except task click/close behavior. +- [x] Task click opens modal. +- [x] Outside click closes modal. +- [x] Close button closes modal. +- [x] Difficulty icon has five bars. +- [x] Medium effort fills three bars. +- [x] Reward icon uses solid sparkle/star symbol. +- [x] Seed data is static and deterministic. +- [x] Seed data is represented as backend/domain/application state. +- [x] UI does not duplicate scheduling rules. +- [x] UI does not import scheduler `src/` files. +- [x] UI does not import SQLite/Drift/platform notification APIs. +- [x] Root backend packages remain unchanged. +- [x] Tests and validations are reported. + +## Verification + +1. `scripts/bootstrap_dev.sh`: passed. +2. `flutter pub get`: passed. +3. `dart format lib test`: passed. +4. `flutter analyze`: passed, no issues found. +5. `flutter test`: passed, 6 tests. +6. `scripts/test.sh`: passed, including root `dart analyze`, 338 Dart tests, + 83.27% package coverage, documentation checks, and API doc generation. diff --git a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_EXECUTION_ORDER.md b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_EXECUTION_ORDER.md index eebdac0..eb87760 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_EXECUTION_ORDER.md +++ b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_EXECUTION_ORDER.md @@ -1,6 +1,6 @@ # UI Plan 1 Execution Order -**Status:** Planned +**Status:** Complete on 2026-06-30. Run these files in order. All chunks are intended for `xhigh` execution. diff --git a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_SUMMARY.md b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_SUMMARY.md index 1655eb2..7eb8c31 100644 --- a/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_SUMMARY.md +++ b/Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/UI_PLAN_1_SUMMARY.md @@ -1,6 +1,6 @@ # UI Plan 1 — Compact Today Timeline Mockup Implementation -**Status:** Planned +**Status:** Complete on 2026-06-30. **Plan folder:** `Codex Documentation/Current Software Plan/UI Plan 1 - Compact Today Timeline Mockup/` **Primary target:** Replace the current/provisional Flutter UI with the first real compact Today timeline screen and selected-task modal shown in the bundled mockups. **Execution mode:** Run all chunks at `xhigh`; this plan intentionally does not repeat per-chunk thinking labels. diff --git a/apps/focus_flow_flutter/README.md b/apps/focus_flow_flutter/README.md index fa8185b..eb08250 100644 --- a/apps/focus_flow_flutter/README.md +++ b/apps/focus_flow_flutter/README.md @@ -4,13 +4,21 @@ Flutter desktop app target for the FocusFlow UI work. ## Current Scope -UI Plan 1 is limited to a compact Today timeline mockup and selected-task -modal. The app target already existed before UI Plan 1 Block 1, so this package -keeps the existing Flutter desktop scaffolding and local scheduler dependency. +UI Plan 1 implements a compact Today timeline mockup and selected-task modal. +The app target already existed before UI Plan 1 Block 1, so this package keeps +the existing Flutter desktop scaffolding and local scheduler dependency. -The current Block 1 work establishes structure and package-boundary guardrails. -The visual shell, seeded Today state, timeline cards, and modal are implemented -in later UI Plan 1 blocks. +The app launches into the compact Today screen. All visible controls are +intentionally no-op except selecting a task card and closing the modal. + +## Seed Data + +Static demo data lives in `lib/app/demo_scheduler_composition.dart`. It creates +domain `ProjectProfile`, `OwnerSettings`, and `Task` records in an in-memory +application store, then reads `TodayState` through `GetTodayStateQuery`. + +Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`. +Scheduler core remains the source of truth for task state and Today ordering. ## Package Boundary @@ -24,6 +32,16 @@ The app must not import scheduler `src/` files, SQLite/Drift adapters, desktop notification implementations, backup/export packages, or direct OS APIs for this UI slice. `test/forbidden_imports_test.dart` enforces that boundary. +## Intentionally No-op + +- Sidebar navigation. +- Date navigation and calendar buttons. +- Compact/Normal toggle. +- Settings button. +- Show upcoming. +- Timeline card action icons. +- Modal Done, Push, Move to Backlog, and Break up buttons. + ## Commands ```sh @@ -33,3 +51,11 @@ flutter test ``` Run these from `apps/focus_flow_flutter/`. + +## Next Plans + +- Wire functional Done, Push, Move to Backlog, and Break up actions. +- Add Backlog and quick-capture UI. +- Add persistent SQLite-backed runtime composition. +- Add real navigation/settings. +- Add Shield/Recovery only in a later scope. diff --git a/apps/focus_flow_flutter/lib/app.dart b/apps/focus_flow_flutter/lib/app.dart index 4d5cbd7..bed7138 100644 --- a/apps/focus_flow_flutter/lib/app.dart +++ b/apps/focus_flow_flutter/lib/app.dart @@ -1,97 +1 @@ -import 'package:flutter/material.dart'; - -import 'composition/demo_scheduler_composition.dart'; -import 'theme/focus_flow_theme.dart'; -import 'widgets/backlog_pane.dart'; -import 'widgets/today_pane.dart'; - -class FocusFlowApp extends StatelessWidget { - const FocusFlowApp({required this.composition, super.key}); - - final DemoSchedulerComposition composition; - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'FocusFlow', - debugShowCheckedModeBanner: false, - theme: FocusFlowTheme.dark(), - home: SchedulerHome(composition: composition), - ); - } -} - -class SchedulerHome extends StatefulWidget { - const SchedulerHome({required this.composition, super.key}); - - final DemoSchedulerComposition composition; - - @override - State createState() => _SchedulerHomeState(); -} - -class _SchedulerHomeState extends State { - late final todayController = widget.composition.createTodayController(); - late final backlogController = widget.composition.createBacklogController(); - late final commandController = widget.composition.createCommandController( - refreshReads: () async { - await Future.wait([todayController.load(), backlogController.load()]); - }, - ); - int selectedIndex = 0; - - @override - void initState() { - super.initState(); - todayController.load(); - backlogController.load(); - } - - @override - void dispose() { - todayController.dispose(); - backlogController.dispose(); - commandController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('FocusFlow')), - body: IndexedStack( - index: selectedIndex, - children: [ - TodayPane( - controller: todayController, - commandController: commandController, - ), - BacklogPane( - controller: backlogController, - commandController: commandController, - ), - ], - ), - bottomNavigationBar: NavigationBar( - selectedIndex: selectedIndex, - onDestinationSelected: (index) { - setState(() { - selectedIndex = index; - }); - }, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.today_outlined), - selectedIcon: Icon(Icons.today), - label: 'Today', - ), - NavigationDestination( - icon: Icon(Icons.inbox_outlined), - selectedIcon: Icon(Icons.inbox), - label: 'Backlog', - ), - ], - ), - ); - } -} +export 'app/focus_flow_app.dart' show FocusFlowApp; diff --git a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index 513c62b..3a136bc 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -1,2 +1,304 @@ -export '../composition/demo_scheduler_composition.dart' - show DemoSchedulerComposition; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../controllers/scheduler_command_controller.dart'; +import '../controllers/scheduler_read_controller.dart'; +import '../controllers/today_screen_controller.dart'; + +class DemoSchedulerComposition { + DemoSchedulerComposition._({ + required this.store, + required this.todayQuery, + required this.managementUseCases, + required this.commandUseCases, + required this.date, + required this.readAt, + }); + + static const ownerId = 'owner-1'; + static const timeZoneId = 'UTC'; + static const projectId = 'home'; + + final InMemoryApplicationUnitOfWork store; + final GetTodayStateQuery todayQuery; + final V1ApplicationManagementUseCases managementUseCases; + final V1ApplicationCommandUseCases commandUseCases; + final CivilDate date; + final DateTime readAt; + + String get dateLabel => formatDateLabel(date); + + factory DemoSchedulerComposition.seeded({ + Clock? clock, + CivilDate? selectedDate, + bool includeTodayItems = true, + bool includeBacklogItems = true, + }) { + final resolvedDate = + selectedDate ?? + 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 tasks = [ + if (includeTodayItems) ..._todaySeedTasks(resolvedDate, createdAt), + if (includeBacklogItems) + Task.quickCapture( + id: 'later-backlog-note', + title: 'Review reusable grocery list', + projectId: projectId, + createdAt: createdAt, + updatedAt: createdAt, + ), + ]; + final store = InMemoryApplicationUnitOfWork( + initialProjects: [project], + initialOwnerSettings: [ + OwnerSettings( + ownerId: ownerId, + timeZoneId: timeZoneId, + compactModeEnabled: true, + ), + ], + initialTasks: tasks, + ); + + return DemoSchedulerComposition._( + store: store, + todayQuery: GetTodayStateQuery( + applicationStore: store, + timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + ), + managementUseCases: V1ApplicationManagementUseCases( + applicationStore: store, + ), + commandUseCases: V1ApplicationCommandUseCases( + applicationStore: store, + timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), + ), + date: resolvedDate, + readAt: readAt, + ); + } + + TodayScreenController createTodayScreenController() { + return TodayScreenController( + read: () { + return todayQuery.execute( + GetTodayStateRequest( + context: context('ui-plan-1-read-today'), + date: date, + includeNextFlexibleItem: true, + ), + ); + }, + ); + } + + UiReadController createTodayController() { + return ApplicationReadController( + read: () { + return todayQuery.execute( + GetTodayStateRequest( + context: context('ui-read-today'), + date: date, + includeNextFlexibleItem: true, + ), + ); + }, + isEmpty: (state) { + return state.timelineItems.isEmpty && state.pendingNotices.isEmpty; + }, + ); + } + + UiReadController createBacklogController() { + return ApplicationReadController( + read: () { + return managementUseCases.getBacklog( + GetBacklogRequest( + context: context('ui-read-backlog'), + sortKey: BacklogSortKey.age, + ), + ); + }, + isEmpty: (state) => state.items.isEmpty, + ); + } + + SchedulerCommandController createCommandController({ + required ReadRefresh refreshReads, + }) { + return SchedulerCommandController( + commands: commandUseCases, + contextFor: context, + localDate: date, + refreshReads: refreshReads, + ); + } + + ApplicationOperationContext context(String operationId) { + return ApplicationOperationContext.start( + operationId: operationId, + ownerTimeZone: OwnerTimeZoneContext( + ownerId: ownerId, + timeZoneId: timeZoneId, + ), + clock: FixedClock(readAt), + idGenerator: SequentialIdGenerator(prefix: operationId), + ); + } + + static String formatDateLabel(CivilDate date) { + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + return '${months[date.month - 1]} ${date.day}, ${date.year}'; + } + + static List _todaySeedTasks(CivilDate date, DateTime createdAt) { + return [ + _task( + id: 'clean-coffee-maker', + title: 'Clean coffee maker', + type: TaskType.flexible, + status: TaskStatus.planned, + date: date, + startHour: 16, + startMinute: 15, + endHour: 16, + endMinute: 30, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.easy, + createdAt: createdAt, + ), + _task( + id: 'cleaned-kitchen-counter-early', + title: 'Cleaned kitchen counter', + type: TaskType.surprise, + status: TaskStatus.completed, + date: date, + startHour: 16, + startMinute: 45, + endHour: 17, + endMinute: 0, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + completedAt: _instant(date, 16, 20), + createdAt: createdAt, + ), + _task( + id: 'pay-bill', + title: 'Pay bill', + type: TaskType.critical, + status: TaskStatus.planned, + date: date, + startHour: 18, + startMinute: 0, + endHour: 18, + endMinute: 10, + reward: RewardLevel.low, + difficulty: DifficultyLevel.medium, + createdAt: createdAt, + ), + _task( + id: 'doctor-appointment', + title: 'Doctor appointment', + type: TaskType.inflexible, + status: TaskStatus.planned, + date: date, + startHour: 18, + startMinute: 30, + endHour: 19, + endMinute: 15, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.hard, + createdAt: createdAt, + ), + _task( + id: 'free-slot', + title: 'Free Slot', + type: TaskType.freeSlot, + status: TaskStatus.planned, + date: date, + startHour: 19, + startMinute: 15, + endHour: 20, + endMinute: 15, + reward: RewardLevel.notSet, + difficulty: DifficultyLevel.notSet, + createdAt: createdAt, + ), + _task( + id: 'cleaned-kitchen-counter-late', + title: 'Cleaned kitchen counter', + type: TaskType.surprise, + status: TaskStatus.completed, + date: date, + startHour: 20, + startMinute: 15, + endHour: 20, + endMinute: 30, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + completedAt: _instant(date, 20, 20), + createdAt: createdAt, + ), + ]; + } + + static Task _task({ + required String id, + required String title, + required TaskType type, + required TaskStatus status, + required CivilDate date, + required int startHour, + required int startMinute, + required int endHour, + required int endMinute, + required RewardLevel reward, + required DifficultyLevel difficulty, + required DateTime createdAt, + DateTime? completedAt, + }) { + final start = _instant(date, startHour, startMinute); + final end = _instant(date, endHour, endMinute); + return Task( + id: id, + title: title, + projectId: projectId, + type: type, + status: status, + priority: PriorityLevel.medium, + reward: reward, + difficulty: difficulty, + durationMinutes: end.difference(start).inMinutes, + scheduledStart: start, + scheduledEnd: end, + actualStart: status == TaskStatus.completed ? start : null, + actualEnd: status == TaskStatus.completed ? end : null, + completedAt: completedAt, + createdAt: createdAt, + updatedAt: completedAt ?? createdAt, + ); + } + + static DateTime _instant(CivilDate date, int hour, [int minute = 0]) { + return DateTime.utc(date.year, date.month, date.day, hour, minute); + } +} diff --git a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart index 70f976a..3dd6cf7 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -1 +1,167 @@ -export '../app.dart' show FocusFlowApp; +import 'package:flutter/material.dart'; + +import '../controllers/today_screen_controller.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/required_banner.dart'; +import '../widgets/sidebar.dart'; +import '../widgets/task_selection_modal.dart'; +import '../widgets/timeline/timeline_view.dart'; +import '../widgets/top_bar.dart'; +import 'demo_scheduler_composition.dart'; + +class FocusFlowApp extends StatelessWidget { + const FocusFlowApp({required this.composition, super.key}); + + final DemoSchedulerComposition composition; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'FocusFlow', + debugShowCheckedModeBanner: false, + theme: FocusFlowTheme.dark(), + home: FocusFlowHome(composition: composition), + ); + } +} + +class FocusFlowHome extends StatefulWidget { + const FocusFlowHome({required this.composition, super.key}); + + final DemoSchedulerComposition composition; + + @override + State createState() => _FocusFlowHomeState(); +} + +class _FocusFlowHomeState extends State { + late final TodayScreenController controller = widget.composition + .createTodayScreenController(); + + @override + void initState() { + super.initState(); + controller.load(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: FocusFlowTokens.appBackground, + body: AppShell( + sidebar: const Sidebar(), + child: AnimatedBuilder( + animation: controller, + builder: (context, _) { + return switch (controller.state) { + TodayScreenLoading() => const Center( + child: CircularProgressIndicator(), + ), + TodayScreenFailure(:final message) => _PlanIssue( + message: message, + ), + TodayScreenEmpty() => _TodayScreenScaffold( + data: TodayScreenData.empty( + dateLabel: widget.composition.dateLabel, + ), + selectedCard: controller.selectedCard, + onSelectCard: controller.selectCard, + onClearSelection: controller.clearSelection, + ), + TodayScreenReady(:final data) => _TodayScreenScaffold( + data: data, + selectedCard: controller.selectedCard, + onSelectCard: controller.selectCard, + onClearSelection: controller.clearSelection, + ), + }; + }, + ), + ), + ); + } +} + +class _PlanIssue extends StatelessWidget { + const _PlanIssue({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Text(message, style: Theme.of(context).textTheme.titleMedium), + ); + } +} + +class _TodayScreenScaffold extends StatelessWidget { + const _TodayScreenScaffold({ + required this.data, + required this.selectedCard, + required this.onSelectCard, + required this.onClearSelection, + }); + + final TodayScreenData data; + final TimelineCardModel? selectedCard; + final ValueChanged onSelectCard; + final VoidCallback onClearSelection; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + FocusFlowTokens.mainGutter, + 32, + FocusFlowTokens.mainGutter, + 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TopBar(dateLabel: data.dateLabel), + const SizedBox(height: 24), + RequiredBanner(model: data.requiredBanner), + const SizedBox(height: 22), + Expanded( + child: TimelineView( + cards: data.cards, + range: data.timelineRange, + onCardSelected: onSelectCard, + ), + ), + ], + ), + ), + if (selectedCard != null) ...[ + Positioned.fill( + child: GestureDetector( + key: const ValueKey('modal-click-away'), + behavior: HitTestBehavior.opaque, + onTap: onClearSelection, + child: const ColoredBox(color: Colors.transparent), + ), + ), + Center( + child: TaskSelectionModal( + card: selectedCard!, + onClose: onClearSelection, + ), + ), + ], + ], + ); + } +} diff --git a/apps/focus_flow_flutter/lib/composition/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/composition/demo_scheduler_composition.dart index 6792a7a..0ded2cc 100644 --- a/apps/focus_flow_flutter/lib/composition/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/composition/demo_scheduler_composition.dart @@ -1,196 +1 @@ -import 'package:scheduler_core/scheduler_core.dart'; - -import '../controllers/scheduler_command_controller.dart'; -import '../controllers/scheduler_read_controller.dart'; - -class DemoSchedulerComposition { - DemoSchedulerComposition._({ - required this.store, - required this.todayQuery, - required this.managementUseCases, - required this.commandUseCases, - required this.now, - required this.date, - }); - - final InMemoryApplicationUnitOfWork store; - final GetTodayStateQuery todayQuery; - final V1ApplicationManagementUseCases managementUseCases; - final V1ApplicationCommandUseCases commandUseCases; - final DateTime now; - final CivilDate date; - - factory DemoSchedulerComposition.seeded({ - bool includeTodayItems = true, - bool includeBacklogItems = true, - }) { - final now = DateTime.utc(2026, 6, 25, 9, 15); - final date = CivilDate(2026, 6, 25); - final createdAt = DateTime.utc(2026, 6, 20, 8); - final project = ProjectProfile(id: 'home', name: 'Home', colorKey: 'home'); - final store = InMemoryApplicationUnitOfWork( - initialProjects: [project], - initialOwnerSettings: [ - OwnerSettings( - ownerId: 'owner-1', - timeZoneId: 'UTC', - compactModeEnabled: true, - backlogStalenessSettings: const BacklogStalenessSettings( - greenMaxAge: Duration(days: 2), - blueMaxAge: Duration(days: 6), - ), - ), - ], - initialTasks: [ - if (includeTodayItems) ...[ - _scheduledTask( - id: 'plan-day', - title: 'Plan the day', - type: TaskType.flexible, - start: DateTime.utc(2026, 6, 25, 9), - end: DateTime.utc(2026, 6, 25, 9, 30), - createdAt: createdAt, - ), - _scheduledTask( - id: 'appointment', - title: 'Appointment', - type: TaskType.inflexible, - start: DateTime.utc(2026, 6, 25, 10), - end: DateTime.utc(2026, 6, 25, 10, 45), - createdAt: createdAt, - ), - _scheduledTask( - id: 'rest', - title: 'Protected rest', - type: TaskType.freeSlot, - start: DateTime.utc(2026, 6, 25, 11), - end: DateTime.utc(2026, 6, 25, 11, 30), - createdAt: createdAt, - ), - ], - if (includeBacklogItems) ...[ - _backlogTask( - id: 'fresh', - title: 'Send project update', - createdAt: now.subtract(const Duration(days: 1)), - ), - _backlogTask( - id: 'stale', - title: 'Sort receipts', - createdAt: now.subtract(const Duration(days: 9)), - ), - ], - ], - ); - - return DemoSchedulerComposition._( - store: store, - todayQuery: GetTodayStateQuery( - applicationStore: store, - timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), - ), - managementUseCases: V1ApplicationManagementUseCases( - applicationStore: store, - ), - commandUseCases: V1ApplicationCommandUseCases( - applicationStore: store, - timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(), - ), - now: now, - date: date, - ); - } - - UiReadController createTodayController() { - return ApplicationReadController( - read: () { - return todayQuery.execute( - GetTodayStateRequest( - context: context('ui-read-today'), - date: date, - includeNextFlexibleItem: true, - ), - ); - }, - isEmpty: (state) { - return state.timelineItems.isEmpty && state.pendingNotices.isEmpty; - }, - ); - } - - UiReadController createBacklogController() { - return ApplicationReadController( - read: () { - return managementUseCases.getBacklog( - GetBacklogRequest( - context: context('ui-read-backlog'), - sortKey: BacklogSortKey.age, - ), - ); - }, - isEmpty: (state) => state.items.isEmpty, - ); - } - - SchedulerCommandController createCommandController({ - required ReadRefresh refreshReads, - }) { - return SchedulerCommandController( - commands: commandUseCases, - contextFor: context, - localDate: date, - refreshReads: refreshReads, - ); - } - - ApplicationOperationContext context(String operationId) { - return ApplicationOperationContext.start( - operationId: operationId, - ownerTimeZone: OwnerTimeZoneContext( - ownerId: 'owner-1', - timeZoneId: 'UTC', - ), - clock: FixedClock(now), - idGenerator: SequentialIdGenerator(prefix: operationId), - ); - } -} - -Task _scheduledTask({ - required String id, - required String title, - required TaskType type, - required DateTime start, - required DateTime end, - required DateTime createdAt, -}) { - return Task( - id: id, - title: title, - projectId: 'home', - type: type, - status: TaskStatus.planned, - priority: PriorityLevel.medium, - reward: RewardLevel.medium, - difficulty: DifficultyLevel.easy, - durationMinutes: end.difference(start).inMinutes, - scheduledStart: start, - scheduledEnd: end, - createdAt: createdAt, - updatedAt: createdAt, - ); -} - -Task _backlogTask({ - required String id, - required String title, - required DateTime createdAt, -}) { - return Task.quickCapture( - id: id, - title: title, - projectId: 'home', - createdAt: createdAt, - updatedAt: createdAt, - ); -} +export '../app/demo_scheduler_composition.dart' show DemoSchedulerComposition; diff --git a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart index 4a5149c..0873fb0 100644 --- a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart @@ -1,5 +1,82 @@ +import 'package:flutter/foundation.dart'; import 'package:scheduler_core/scheduler_core.dart'; -import 'scheduler_read_controller.dart'; +import '../models/today_screen_models.dart'; -typedef TodayScreenController = UiReadController; +typedef TodayStateReader = Future> Function(); + +sealed class TodayScreenState { + const TodayScreenState(); +} + +class TodayScreenLoading extends TodayScreenState { + const TodayScreenLoading(); +} + +class TodayScreenReady extends TodayScreenState { + const TodayScreenReady(this.data); + + final TodayScreenData data; +} + +class TodayScreenEmpty extends TodayScreenState { + const TodayScreenEmpty(); +} + +class TodayScreenFailure extends TodayScreenState { + const TodayScreenFailure(this.message); + + final String message; +} + +class TodayScreenController extends ChangeNotifier { + TodayScreenController({required this.read}); + + final TodayStateReader read; + + TodayScreenState _state = const TodayScreenLoading(); + TimelineCardModel? _selectedCard; + + TodayScreenState get state => _state; + + TimelineCardModel? get selectedCard => _selectedCard; + + Future load() async { + _state = const TodayScreenLoading(); + notifyListeners(); + try { + final result = await read(); + final failure = result.failure; + if (failure != null) { + _state = TodayScreenFailure(failure.detailCode ?? failure.code.name); + notifyListeners(); + return; + } + + final data = TodayScreenData.fromTodayState(result.requireValue); + _state = data.cards.isEmpty + ? const TodayScreenEmpty() + : TodayScreenReady(data); + notifyListeners(); + } on Object catch (error) { + _state = TodayScreenFailure(error.toString()); + notifyListeners(); + } + } + + void selectCard(TimelineCardModel card) { + if (!card.isSelectable) { + return; + } + _selectedCard = card; + notifyListeners(); + } + + void clearSelection() { + if (_selectedCard == null) { + return; + } + _selectedCard = null; + notifyListeners(); + } +} diff --git a/apps/focus_flow_flutter/lib/models/today_screen_models.dart b/apps/focus_flow_flutter/lib/models/today_screen_models.dart index 003bd9a..834633c 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -1,3 +1,5 @@ +import 'package:scheduler_core/scheduler_core.dart'; + class RequiredBannerModel { const RequiredBannerModel({required this.title, required this.timeText}); @@ -5,15 +7,66 @@ class RequiredBannerModel { final String timeText; } +class TimelineRangeModel { + const TimelineRangeModel({ + required this.startMinutes, + required this.endMinutes, + }); + + final int startMinutes; + final int endMinutes; +} + +enum TaskVisualKind { + flexible, + required, + appointment, + freeSlot, + completedSurprise, +} + class TodayScreenData { const TodayScreenData({ required this.dateLabel, + required this.timelineRange, required this.cards, this.requiredBanner, }); + factory TodayScreenData.empty({required String dateLabel}) { + return TodayScreenData( + dateLabel: dateLabel, + timelineRange: defaultRange, + cards: const [], + ); + } + + factory TodayScreenData.fromTodayState(TodayState state) { + final cards = [ + for (final item in state.timelineItems) TimelineCardModel.fromItem(item), + ]; + final nextRequired = state.nextRequiredItem; + return TodayScreenData( + dateLabel: _dateLabel(state.date), + timelineRange: defaultRange, + requiredBanner: nextRequired == null + ? null + : RequiredBannerModel( + title: nextRequired.item.displayTitle, + timeText: _formatTime(nextRequired.start), + ), + cards: List.unmodifiable(cards), + ); + } + + static const defaultRange = TimelineRangeModel( + startMinutes: 16 * 60, + endMinutes: 20 * 60 + 45, + ); + final String dateLabel; final RequiredBannerModel? requiredBanner; + final TimelineRangeModel timelineRange; final List cards; } @@ -21,10 +74,164 @@ class TimelineCardModel { const TimelineCardModel({ required this.id, required this.title, + required this.typeLabel, + required this.subtitle, required this.timeText, + required this.startMinutes, + required this.endMinutes, + required this.visualKind, + required this.rewardIconToken, + required this.difficultyIconToken, + required this.isCompleted, + required this.isSelectable, + required this.showsQuickActions, + this.durationMinutes, }); + factory TimelineCardModel.fromItem(TodayTimelineItem item) { + final start = item.start; + final end = item.end; + final visualKind = _visualKindFor(item); + final isCompleted = item.taskStatus == TaskStatus.completed; + final title = item.item.displayTitle; + final duration = item.item.durationMinutes; + final timeText = start == null || end == null + ? duration == null + ? '' + : '$duration min' + : '${_formatTime(start)} - ${_formatTime(end)}'; + return TimelineCardModel( + id: item.id, + title: title, + typeLabel: _typeLabelFor(visualKind), + subtitle: _subtitleFor(item, visualKind, timeText), + timeText: timeText, + startMinutes: _minutesSinceMidnight(start), + endMinutes: _minutesSinceMidnight(end), + visualKind: visualKind, + rewardIconToken: item.item.rewardIconToken, + difficultyIconToken: item.item.difficultyIconToken, + durationMinutes: duration, + isCompleted: isCompleted, + isSelectable: true, + showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot, + ); + } + final String id; final String title; + final String typeLabel; + final String subtitle; final String timeText; + final int startMinutes; + final int endMinutes; + final int? durationMinutes; + final TaskVisualKind visualKind; + final TimelineRewardIconToken rewardIconToken; + final TimelineDifficultyIconToken difficultyIconToken; + final bool isCompleted; + final bool isSelectable; + final bool showsQuickActions; + + String get rewardLabel { + return switch (rewardIconToken) { + TimelineRewardIconToken.notSet => 'Reward not set', + TimelineRewardIconToken.veryLow => 'Reward level 1', + TimelineRewardIconToken.low => 'Reward level 2', + TimelineRewardIconToken.medium => 'Reward level 3', + TimelineRewardIconToken.high => 'Reward level 4', + TimelineRewardIconToken.veryHigh => 'Reward level 5', + }; + } + + String get effortLabel { + return switch (difficultyIconToken) { + TimelineDifficultyIconToken.notSet => 'Effort not set', + TimelineDifficultyIconToken.veryEasy => 'Very easy effort', + TimelineDifficultyIconToken.easy => 'Easy effort', + TimelineDifficultyIconToken.medium => 'Medium effort', + TimelineDifficultyIconToken.hard => 'Hard effort', + TimelineDifficultyIconToken.veryHard => 'Very hard effort', + }; + } +} + +TaskVisualKind _visualKindFor(TodayTimelineItem item) { + if (item.taskStatus == TaskStatus.completed || + (item.taskType == TaskType.surprise && + item.taskStatus == TaskStatus.completed)) { + return TaskVisualKind.completedSurprise; + } + return switch (item.taskType) { + TaskType.flexible => TaskVisualKind.flexible, + TaskType.critical => TaskVisualKind.required, + TaskType.inflexible => TaskVisualKind.appointment, + TaskType.freeSlot => TaskVisualKind.freeSlot, + TaskType.surprise => TaskVisualKind.completedSurprise, + TaskType.locked => TaskVisualKind.completedSurprise, + }; +} + +String _typeLabelFor(TaskVisualKind visualKind) { + return switch (visualKind) { + TaskVisualKind.flexible => 'Flexible', + TaskVisualKind.required => 'Required', + TaskVisualKind.appointment => 'Required', + TaskVisualKind.freeSlot => 'Free Slot', + TaskVisualKind.completedSurprise => 'Surprise task', + }; +} + +String _subtitleFor( + TodayTimelineItem item, + TaskVisualKind visualKind, + String timeText, +) { + if (visualKind == TaskVisualKind.freeSlot) { + return 'Intentional rest'; + } + if (visualKind == TaskVisualKind.completedSurprise) { + final completedAt = _formatTime(item.item.end ?? item.end); + return 'Surprise task - Completed at $completedAt'; + } + if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) { + return '${item.item.durationMinutes} min'; + } + return timeText; +} + +String _dateLabel(CivilDate date) { + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + return '${months[date.month - 1]} ${date.day}, ${date.year}'; +} + +int _minutesSinceMidnight(DateTime? value) { + if (value == null) { + return 0; + } + return value.hour * 60 + value.minute; +} + +String _formatTime(DateTime? value) { + if (value == null) { + return ''; + } + final period = value.hour >= 12 ? 'PM' : 'AM'; + final rawHour = value.hour % 12; + final hour = rawHour == 0 ? 12 : rawHour; + final minute = value.minute.toString().padLeft(2, '0'); + return '$hour:$minute $period'; } diff --git a/apps/focus_flow_flutter/lib/theme/focus_flow_theme.dart b/apps/focus_flow_flutter/lib/theme/focus_flow_theme.dart index 0e99939..ef5963d 100644 --- a/apps/focus_flow_flutter/lib/theme/focus_flow_theme.dart +++ b/apps/focus_flow_flutter/lib/theme/focus_flow_theme.dart @@ -11,6 +11,34 @@ abstract final class FocusFlowTheme { return ThemeData( colorScheme: scheme, scaffoldBackgroundColor: FocusFlowTokens.appBackground, + canvasColor: FocusFlowTokens.appBackground, + dialogTheme: const DialogThemeData( + backgroundColor: FocusFlowTokens.elevatedPanel, + ), + fontFamily: 'Roboto', + textTheme: const TextTheme( + headlineLarge: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: FocusFlowTokens.pageTitleSize, + fontWeight: FontWeight.w800, + ), + titleLarge: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: FocusFlowTokens.cardTitleSize, + fontWeight: FontWeight.w800, + ), + titleMedium: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + bodyLarge: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w500, + ), + bodyMedium: TextStyle(color: FocusFlowTokens.textMuted, fontSize: 15), + ), useMaterial3: true, ); } diff --git a/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart b/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart index 48352fc..1faab95 100644 --- a/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart +++ b/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart @@ -1,27 +1,47 @@ import 'package:flutter/material.dart'; abstract final class FocusFlowTokens { - static const appBackground = Color(0xFF080A12); - static const panelBackground = Color(0xFF11131E); - static const elevatedPanel = Color(0xFF171925); + static const appBackground = Color(0xFF070811); + static const appFrameBorder = Color(0xFF3B3D4E); + static const sidebarBackground = Color(0xFF090A14); + static const panelBackground = Color(0xFF11121D); + static const elevatedPanel = Color(0xFF181724); + static const glassPanel = Color(0x662A1025); + static const glassPanelStrong = Color(0x992A1025); + static const gridLine = Color(0x243B3D4E); + static const rail = Color(0xFF7D7F91); + static const subtleBorder = Color(0xFF3A3A4A); + static const navBorder = Color(0xFFB72D82); + static const accentMagenta = Color(0xFFFF5BA8); static const textPrimary = Color(0xFFF7F2FA); - static const textMuted = Color(0xFFB8B1C5); + static const textMuted = Color(0xFFBBB5C8); + static const textFaint = Color(0xFF8E889D); static const completedText = Color(0xFF8F8A99); + static const flexibleGreen = Color(0xFF4BE064); static const requiredPink = Color(0xFFFF4E7D); static const appointmentBlue = Color(0xFF38A8FF); - static const restPurple = Color(0xFFB05CFF); + static const restPurple = Color(0xFFB45CFF); static const completedGray = Color(0xFF777482); - static const subtleBorder = Color(0xFF3A3A4A); + static const warningYellow = Color(0xFFFFCC66); static const sidebarWidth = 250.0; static const mainGutter = 32.0; - static const timelineRailWidth = 124.0; + static const timelineRailWidth = 120.0; + static const cardHorizontalPadding = 18.0; static const appFrameRadius = 8.0; static const navRadius = 8.0; static const bannerRadius = 8.0; static const cardRadius = 8.0; static const modalRadius = 8.0; + static const smallButtonRadius = 8.0; + + static const pageTitleSize = 40.0; + static const cardTitleSize = 24.0; + static const cardMetaSize = 16.0; + static const sidebarNavSize = 18.0; + static const modalTitleSize = 26.0; + static const modalButtonSize = 18.0; } diff --git a/apps/focus_flow_flutter/lib/widgets/app_shell.dart b/apps/focus_flow_flutter/lib/widgets/app_shell.dart index dea7b24..e590d4d 100644 --- a/apps/focus_flow_flutter/lib/widgets/app_shell.dart +++ b/apps/focus_flow_flutter/lib/widgets/app_shell.dart @@ -10,13 +10,35 @@ class AppShell extends StatelessWidget { @override Widget build(BuildContext context) { - return DecoratedBox( - decoration: const BoxDecoration(color: FocusFlowTokens.appBackground), - child: Row( - children: [ - SizedBox(width: FocusFlowTokens.sidebarWidth, child: sidebar), - Expanded(child: child), - ], + return ColoredBox( + color: FocusFlowTokens.appBackground, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(4), + child: ClipRRect( + borderRadius: BorderRadius.circular(FocusFlowTokens.appFrameRadius), + child: DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.appBackground, + border: Border.all(color: FocusFlowTokens.appFrameBorder), + borderRadius: BorderRadius.circular( + FocusFlowTokens.appFrameRadius, + ), + ), + child: Row( + children: [ + SizedBox(width: FocusFlowTokens.sidebarWidth, child: sidebar), + const VerticalDivider( + width: 1, + thickness: 1, + color: FocusFlowTokens.subtleBorder, + ), + Expanded(child: child), + ], + ), + ), + ), + ), ), ); } diff --git a/apps/focus_flow_flutter/lib/widgets/required_banner.dart b/apps/focus_flow_flutter/lib/widgets/required_banner.dart index 3e2e216..a8d8b19 100644 --- a/apps/focus_flow_flutter/lib/widgets/required_banner.dart +++ b/apps/focus_flow_flutter/lib/widgets/required_banner.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../models/today_screen_models.dart'; +import '../theme/focus_flow_tokens.dart'; class RequiredBanner extends StatelessWidget { const RequiredBanner({this.model, super.key}); @@ -11,8 +12,78 @@ class RequiredBanner extends StatelessWidget { Widget build(BuildContext context) { final banner = model; if (banner == null) { - return const SizedBox.shrink(); + return const SizedBox(height: 72); } - return Text('Next required task: ${banner.title} at ${banner.timeText}'); + return Container( + height: 72, + padding: const EdgeInsets.symmetric(horizontal: 18), + decoration: BoxDecoration( + color: FocusFlowTokens.glassPanel, + borderRadius: BorderRadius.circular(FocusFlowTokens.bannerRadius), + border: Border.all( + color: FocusFlowTokens.navBorder.withValues(alpha: 0.55), + ), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: FocusFlowTokens.glassPanelStrong, + ), + child: const Icon( + Icons.auto_awesome, + color: FocusFlowTokens.accentMagenta, + ), + ), + const SizedBox(width: 26), + RichText( + text: TextSpan( + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 19, + fontWeight: FontWeight.w700, + ), + children: [ + const TextSpan(text: 'Next required task: '), + TextSpan( + text: banner.title, + style: const TextStyle(color: FocusFlowTokens.accentMagenta), + ), + TextSpan( + text: ' at ${banner.timeText}', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ], + ), + ), + const Spacer(), + OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + side: BorderSide( + color: FocusFlowTokens.navBorder.withValues(alpha: 0.55), + ), + fixedSize: const Size(176, 46), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + FocusFlowTokens.smallButtonRadius, + ), + ), + ), + child: const FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [Text('Show upcoming'), Icon(Icons.chevron_right)], + ), + ), + ), + ], + ), + ); } } diff --git a/apps/focus_flow_flutter/lib/widgets/sidebar.dart b/apps/focus_flow_flutter/lib/widgets/sidebar.dart index f4b0faf..f3ad028 100644 --- a/apps/focus_flow_flutter/lib/widgets/sidebar.dart +++ b/apps/focus_flow_flutter/lib/widgets/sidebar.dart @@ -1,10 +1,167 @@ import 'package:flutter/material.dart'; +import '../theme/focus_flow_tokens.dart'; + class Sidebar extends StatelessWidget { const Sidebar({super.key}); @override Widget build(BuildContext context) { - return const SizedBox.shrink(); + return DecoratedBox( + decoration: const BoxDecoration(color: FocusFlowTokens.sidebarBackground), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 22, 18, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _WindowControls(), + const SizedBox(height: 34), + const _BrandRow(), + const SizedBox(height: 34), + _NavItem( + label: 'Today', + icon: Icons.calendar_today, + active: true, + onTap: () {}, + ), + const SizedBox(height: 16), + _NavItem(label: 'Backlog', icon: Icons.layers, onTap: () {}), + const SizedBox(height: 16), + _NavItem(label: 'Projects', icon: Icons.work_outline, onTap: () {}), + const SizedBox(height: 16), + _NavItem(label: 'Reports', icon: Icons.bar_chart, onTap: () {}), + const SizedBox(height: 30), + const Divider(color: FocusFlowTokens.subtleBorder), + const SizedBox(height: 18), + _NavItem(label: 'Settings', icon: Icons.settings, onTap: () {}), + ], + ), + ), + ); + } +} + +class _WindowControls extends StatelessWidget { + const _WindowControls(); + + @override + Widget build(BuildContext context) { + return const Row( + children: [ + _WindowDot(color: Color(0xFFFF5F57)), + SizedBox(width: 12), + _WindowDot(color: Color(0xFFFFBD2E)), + SizedBox(width: 12), + _WindowDot(color: Color(0xFF28C840)), + ], + ); + } +} + +class _WindowDot extends StatelessWidget { + const _WindowDot({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + child: const SizedBox(width: 16, height: 16), + ); + } +} + +class _BrandRow extends StatelessWidget { + const _BrandRow(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: FocusFlowTokens.accentMagenta), + color: FocusFlowTokens.glassPanelStrong, + ), + child: const Icon( + Icons.auto_awesome, + color: FocusFlowTokens.accentMagenta, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + 'FocusFlow', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ); + } +} + +class _NavItem extends StatelessWidget { + const _NavItem({ + required this.label, + required this.icon, + required this.onTap, + this.active = false, + }); + + final String label; + final IconData icon; + final VoidCallback onTap; + final bool active; + + @override + Widget build(BuildContext context) { + final color = active + ? FocusFlowTokens.accentMagenta + : FocusFlowTokens.textPrimary; + return Material( + color: Colors.transparent, + child: InkWell( + key: active ? const ValueKey('nav-today-active') : null, + borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius), + onTap: onTap, + child: Container( + height: 54, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius), + border: Border.all( + color: active ? FocusFlowTokens.navBorder : Colors.transparent, + ), + color: active + ? FocusFlowTokens.glassPanelStrong + : Colors.transparent, + ), + child: Row( + children: [ + Icon(icon, color: color, size: 24), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color.withValues(alpha: active ? 1 : 0.88), + fontSize: FocusFlowTokens.sidebarNavSize, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); } } diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart index 8bc285a..ebb660a 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import '../models/today_screen_models.dart'; +import '../theme/focus_flow_tokens.dart'; +import 'timeline/difficulty_bars.dart'; +import 'timeline/reward_icon.dart'; class TaskSelectionModal extends StatelessWidget { const TaskSelectionModal({ @@ -14,15 +17,192 @@ class TaskSelectionModal extends StatelessWidget { @override Widget build(BuildContext context) { + final accent = _accentFor(card.visualKind); return Material( color: Colors.transparent, - child: Column( - mainAxisSize: MainAxisSize.min, + child: Container( + key: const ValueKey('task-selection-modal'), + width: 548, + padding: const EdgeInsets.fromLTRB(24, 22, 24, 20), + decoration: BoxDecoration( + color: FocusFlowTokens.elevatedPanel.withValues(alpha: 0.98), + borderRadius: BorderRadius.circular(FocusFlowTokens.modalRadius), + border: Border.all(color: accent.withValues(alpha: 0.86)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: accent, width: 4), + ), + ), + const SizedBox(width: 28), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.title, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: FocusFlowTokens.modalTitleSize, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + '${card.typeLabel} - ${card.timeText}', + style: const TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 17, + ), + ), + ], + ), + ), + IconButton( + key: const ValueKey('close-task-modal'), + onPressed: onClose, + style: IconButton.styleFrom( + backgroundColor: FocusFlowTokens.glassPanelStrong, + foregroundColor: FocusFlowTokens.textPrimary, + ), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 26), + Row( + children: [ + Expanded( + child: _InfoTile( + label: card.rewardLabel, + child: RewardIcon(color: FocusFlowTokens.accentMagenta), + ), + ), + const SizedBox(width: 28), + Expanded( + child: _InfoTile( + label: card.effortLabel, + child: DifficultyBars( + difficulty: card.difficultyIconToken, + color: FocusFlowTokens.restPurple, + ), + ), + ), + ], + ), + const SizedBox(height: 30), + GridView.count( + crossAxisCount: 2, + crossAxisSpacing: 26, + mainAxisSpacing: 16, + childAspectRatio: 4.1, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: const [ + _ActionButton(icon: Icons.check, label: 'Done'), + _ActionButton(icon: Icons.arrow_forward, label: 'Push'), + _ActionButton( + icon: Icons.inventory_2_outlined, + label: 'Move to Backlog', + ), + _ActionButton(icon: Icons.apps, label: 'Break up'), + ], + ), + const SizedBox(height: 14), + const Text( + 'Options apply after confirmation.', + style: TextStyle(color: FocusFlowTokens.textMuted, fontSize: 14), + ), + ], + ), + ), + ); + } +} + +class _InfoTile extends StatelessWidget { + const _InfoTile({required this.child, required this.label}); + + final Widget child; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + height: 58, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: Row( children: [ - Text(card.title), - IconButton(onPressed: onClose, icon: const Icon(Icons.close)), + child, + const SizedBox(width: 18), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + ), ], ), ); } } + +class _ActionButton extends StatelessWidget { + const _ActionButton({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + FocusFlowTokens.smallButtonRadius, + ), + ), + ), + icon: Icon(icon), + label: Text( + label, + style: const TextStyle( + fontSize: FocusFlowTokens.modalButtonSize, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +Color _accentFor(TaskVisualKind kind) { + return switch (kind) { + TaskVisualKind.flexible => FocusFlowTokens.flexibleGreen, + TaskVisualKind.required => FocusFlowTokens.requiredPink, + TaskVisualKind.appointment => FocusFlowTokens.appointmentBlue, + TaskVisualKind.freeSlot => FocusFlowTokens.restPurple, + TaskVisualKind.completedSurprise => FocusFlowTokens.completedGray, + }; +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart b/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart index 796dfa9..910ec7e 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/difficulty_bars.dart @@ -1,13 +1,32 @@ import 'package:flutter/material.dart'; import 'package:scheduler_core/scheduler_core.dart'; +import '../../theme/focus_flow_tokens.dart'; + class DifficultyBars extends StatelessWidget { - const DifficultyBars({required this.difficulty, this.color, super.key}); + const DifficultyBars({ + required this.difficulty, + this.color = FocusFlowTokens.restPurple, + this.size = const Size(54, 34), + super.key, + }); - final DifficultyLevel difficulty; - final Color? color; + final TimelineDifficultyIconToken difficulty; + final Color color; + final Size size; - static int fillCount(DifficultyLevel difficulty) { + static int fillCount(TimelineDifficultyIconToken difficulty) { + return switch (difficulty) { + TimelineDifficultyIconToken.notSet => 0, + TimelineDifficultyIconToken.veryEasy => 1, + TimelineDifficultyIconToken.easy => 2, + TimelineDifficultyIconToken.medium => 3, + TimelineDifficultyIconToken.hard => 4, + TimelineDifficultyIconToken.veryHard => 5, + }; + } + + static int fillCountForLevel(DifficultyLevel difficulty) { return switch (difficulty) { DifficultyLevel.notSet => 0, DifficultyLevel.veryEasy => 1, @@ -20,6 +39,55 @@ class DifficultyBars extends StatelessWidget { @override Widget build(BuildContext context) { - return Text('${fillCount(difficulty)}/5'); + return CustomPaint( + size: size, + painter: _DifficultyBarsPainter( + filledCount: fillCount(difficulty), + color: color, + ), + ); + } +} + +class _DifficultyBarsPainter extends CustomPainter { + const _DifficultyBarsPainter({ + required this.filledCount, + required this.color, + }); + + final int filledCount; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final gap = size.width * 0.08; + final barWidth = (size.width - gap * 4) / 5; + final strokePaint = Paint() + ..color = FocusFlowTokens.textFaint + ..style = PaintingStyle.stroke + ..strokeWidth = 1.3; + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + for (var index = 0; index < 5; index += 1) { + final left = index * (barWidth + gap); + final height = size.height * (0.34 + index * 0.13); + final top = size.height - height; + final rect = RRect.fromRectAndRadius( + Rect.fromLTWH(left, top, barWidth, height), + Radius.circular(barWidth / 2), + ); + if (index < filledCount) { + canvas.drawRRect(rect, fillPaint); + } else { + canvas.drawRRect(rect, strokePaint); + } + } + } + + @override + bool shouldRepaint(covariant _DifficultyBarsPainter oldDelegate) { + return oldDelegate.filledCount != filledCount || oldDelegate.color != color; } } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/reward_icon.dart b/apps/focus_flow_flutter/lib/widgets/timeline/reward_icon.dart index 6bc9c82..cfab3a7 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/reward_icon.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/reward_icon.dart @@ -1,12 +1,72 @@ import 'package:flutter/material.dart'; -class RewardIcon extends StatelessWidget { - const RewardIcon({this.color, super.key}); +import '../../theme/focus_flow_tokens.dart'; - final Color? color; +class RewardIcon extends StatelessWidget { + const RewardIcon({ + this.color = FocusFlowTokens.accentMagenta, + this.size = 24, + super.key, + }); + + final Color color; + final double size; @override Widget build(BuildContext context) { - return Icon(Icons.auto_awesome, color: color); + return CustomPaint( + size: Size.square(size), + painter: _RewardIconPainter(color), + ); + } +} + +class _RewardIconPainter extends CustomPainter { + const _RewardIconPainter(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + _drawSparkle( + canvas, + paint, + size.center(Offset.zero), + size.shortestSide * 0.44, + ); + _drawSparkle( + canvas, + paint, + Offset(size.width * 0.78, size.height * 0.24), + size.shortestSide * 0.18, + ); + _drawSparkle( + canvas, + paint, + Offset(size.width * 0.24, size.height * 0.78), + size.shortestSide * 0.14, + ); + } + + void _drawSparkle(Canvas canvas, Paint paint, Offset center, double radius) { + final path = Path() + ..moveTo(center.dx, center.dy - radius) + ..lineTo(center.dx + radius * 0.24, center.dy - radius * 0.24) + ..lineTo(center.dx + radius, center.dy) + ..lineTo(center.dx + radius * 0.24, center.dy + radius * 0.24) + ..lineTo(center.dx, center.dy + radius) + ..lineTo(center.dx - radius * 0.24, center.dy + radius * 0.24) + ..lineTo(center.dx - radius, center.dy) + ..lineTo(center.dx - radius * 0.24, center.dy - radius * 0.24) + ..close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _RewardIconPainter oldDelegate) { + return oldDelegate.color != color; } } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart index d2a3a20..7858a7e 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart @@ -1,15 +1,231 @@ import 'package:flutter/material.dart'; import '../../models/today_screen_models.dart'; +import '../../theme/focus_flow_tokens.dart'; +import 'difficulty_bars.dart'; +import 'reward_icon.dart'; class TaskTimelineCard extends StatelessWidget { - const TaskTimelineCard({required this.card, this.onTap, super.key}); + const TaskTimelineCard({required this.card, required this.onTap, super.key}); final TimelineCardModel card; - final VoidCallback? onTap; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return InkWell(onTap: onTap, child: Text(card.title)); + final colors = _CardColors.forKind(card.visualKind); + return Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey(card.id), + borderRadius: BorderRadius.circular(FocusFlowTokens.cardRadius), + onTap: onTap, + child: Container( + padding: const EdgeInsets.fromLTRB(18, 10, 14, 10), + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(FocusFlowTokens.cardRadius), + border: Border.all(color: colors.accent, width: 1.2), + boxShadow: [ + BoxShadow( + color: colors.accent.withValues(alpha: 0.12), + blurRadius: 18, + spreadRadius: 1, + ), + ], + ), + child: Row( + children: [ + _StatusRing(card: card, color: colors.accent), + const SizedBox(width: 22), + Expanded( + child: _CardText(card: card, color: colors.accent), + ), + if (card.showsQuickActions) ...[ + _NoOpIcon(icon: Icons.check, color: colors.accent), + _NoOpIcon(icon: Icons.arrow_forward, color: colors.accent), + _NoOpIcon(icon: Icons.calendar_today, color: colors.accent), + _NoOpIcon(icon: Icons.wb_sunny_outlined, color: colors.accent), + const SizedBox(width: 10), + ], + _Badge(child: RewardIcon(color: colors.reward, size: 26)), + const SizedBox(width: 8), + _Badge( + child: DifficultyBars( + difficulty: card.difficultyIconToken, + color: colors.reward, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _StatusRing extends StatelessWidget { + const _StatusRing({required this.card, required this.color}); + + final TimelineCardModel card; + final Color color; + + @override + Widget build(BuildContext context) { + final border = Border.all( + color: color, + width: card.visualKind == TaskVisualKind.freeSlot ? 2 : 3, + style: card.visualKind == TaskVisualKind.freeSlot + ? BorderStyle.solid + : BorderStyle.solid, + ); + return Container( + width: 34, + height: 34, + decoration: BoxDecoration(shape: BoxShape.circle, border: border), + child: card.isCompleted + ? Icon(Icons.check, color: color, size: 24) + : null, + ); + } +} + +class _CardText extends StatelessWidget { + const _CardText({required this.card, required this.color}); + + final TimelineCardModel card; + final Color color; + + @override + Widget build(BuildContext context) { + final titleStyle = TextStyle( + color: card.isCompleted + ? FocusFlowTokens.completedText + : FocusFlowTokens.textPrimary, + fontSize: FocusFlowTokens.cardTitleSize, + fontWeight: FontWeight.w800, + decoration: card.isCompleted ? TextDecoration.lineThrough : null, + decorationColor: FocusFlowTokens.completedText, + decorationThickness: 2, + ); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + const SizedBox(height: 4), + Text( + card.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: card.isCompleted ? FocusFlowTokens.completedText : color, + fontSize: FocusFlowTokens.cardMetaSize, + fontStyle: card.visualKind == TaskVisualKind.freeSlot + ? FontStyle.italic + : FontStyle.normal, + fontWeight: card.visualKind == TaskVisualKind.flexible + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + if (card.visualKind == TaskVisualKind.freeSlot) ...[ + const SizedBox(height: 10), + Text( + card.timeText, + style: TextStyle( + color: color, + fontSize: FocusFlowTokens.cardMetaSize, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ); + } +} + +class _NoOpIcon extends StatelessWidget { + const _NoOpIcon({required this.icon, required this.color}); + + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () {}, + color: color, + icon: Icon(icon), + tooltip: 'No-op action', + ); + } +} + +class _Badge extends StatelessWidget { + const _Badge({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + width: 62, + height: 48, + alignment: Alignment.center, + decoration: BoxDecoration( + color: FocusFlowTokens.panelBackground.withValues(alpha: 0.84), + borderRadius: BorderRadius.circular(7), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: child, + ); + } +} + +class _CardColors { + const _CardColors({ + required this.accent, + required this.background, + required this.reward, + }); + + final Color accent; + final Color background; + final Color reward; + + factory _CardColors.forKind(TaskVisualKind kind) { + return switch (kind) { + TaskVisualKind.flexible => const _CardColors( + accent: FocusFlowTokens.flexibleGreen, + background: Color(0xCC021A0C), + reward: FocusFlowTokens.flexibleGreen, + ), + TaskVisualKind.required => const _CardColors( + accent: FocusFlowTokens.requiredPink, + background: Color(0xCC230814), + reward: FocusFlowTokens.requiredPink, + ), + TaskVisualKind.appointment => const _CardColors( + accent: FocusFlowTokens.appointmentBlue, + background: Color(0xCC031429), + reward: FocusFlowTokens.appointmentBlue, + ), + TaskVisualKind.freeSlot => const _CardColors( + accent: FocusFlowTokens.restPurple, + background: Color(0xCC210A33), + reward: FocusFlowTokens.restPurple, + ), + TaskVisualKind.completedSurprise => const _CardColors( + accent: FocusFlowTokens.completedGray, + background: Color(0xAA1C1D25), + reward: FocusFlowTokens.restPurple, + ), + }; } } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart index 24e5793..1442af9 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../theme/focus_flow_tokens.dart'; import 'timeline_geometry.dart'; class TimelineAxis extends StatelessWidget { @@ -9,6 +10,91 @@ class TimelineAxis extends StatelessWidget { @override Widget build(BuildContext context) { - return const SizedBox.shrink(); + final ticks = geometry.ticks(); + return SizedBox( + width: FocusFlowTokens.timelineRailWidth, + height: geometry.totalHeight + 24, + child: Stack( + children: [ + Positioned( + left: 92, + top: 0, + bottom: 0, + child: Container(width: 2, color: FocusFlowTokens.rail), + ), + for (final tick in ticks) + Positioned( + top: tick.y - 10, + left: 0, + width: 86, + child: Text( + tick.label, + textAlign: TextAlign.right, + style: TextStyle( + color: tick.major + ? FocusFlowTokens.textPrimary + : FocusFlowTokens.textFaint, + fontSize: tick.major ? 16 : 14, + fontWeight: tick.major ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + for (final tick in ticks.where((tick) => tick.major)) + Positioned( + top: tick.y - 6, + left: 87, + child: const DecoratedBox( + decoration: BoxDecoration( + color: FocusFlowTokens.rail, + shape: BoxShape.circle, + ), + child: SizedBox(width: 12, height: 12), + ), + ), + ], + ), + ); + } +} + +class TimelineGrid extends StatelessWidget { + const TimelineGrid({required this.geometry, super.key}); + + final TimelineGeometry geometry; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _TimelineGridPainter(geometry), + size: Size.infinite, + ); + } +} + +class _TimelineGridPainter extends CustomPainter { + const _TimelineGridPainter(this.geometry); + + final TimelineGeometry geometry; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = FocusFlowTokens.gridLine + ..strokeWidth = 1; + for (final tick in geometry.ticks()) { + canvas.drawLine( + Offset(0, tick.y), + Offset(size.width, tick.y), + paint + ..color = tick.major + ? FocusFlowTokens.gridLine.withValues(alpha: 0.72) + : FocusFlowTokens.gridLine, + ); + } + } + + @override + bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) { + return oldDelegate.geometry != geometry; } } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart index 634aced..7d41472 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart @@ -5,7 +5,7 @@ class TimelineGeometry { required this.visibleStart, required this.visibleEnd, required this.pixelsPerMinute, - this.minCardHeight = 48, + this.minCardHeight = 72, }); final TimeOfDay visibleStart; @@ -13,13 +13,60 @@ class TimelineGeometry { final double pixelsPerMinute; final double minCardHeight; + int get startMinutes => visibleStart.hour * 60 + visibleStart.minute; + + int get endMinutes => visibleEnd.hour * 60 + visibleEnd.minute; + + double get totalHeight => (endMinutes - startMinutes) * pixelsPerMinute; + double yForMinutesSinceMidnight(int minutes) { - final start = visibleStart.hour * 60 + visibleStart.minute; - return (minutes - start) * pixelsPerMinute; + return (minutes - startMinutes) * pixelsPerMinute; } double heightForDuration(int minutes) { final height = minutes * pixelsPerMinute; return height < minCardHeight ? minCardHeight : height; } + + List ticks({int stepMinutes = 15}) { + return [ + for ( + var minute = startMinutes; + minute <= endMinutes; + minute += stepMinutes + ) + TimelineTick( + minutesSinceMidnight: minute, + y: yForMinutesSinceMidnight(minute), + label: _labelFor(minute), + major: minute % 60 == 0, + ), + ]; + } + + static String _labelFor(int minutes) { + final hour24 = minutes ~/ 60; + final minute = minutes % 60; + final period = hour24 >= 12 ? 'PM' : 'AM'; + final rawHour = hour24 % 12; + final hour = rawHour == 0 ? 12 : rawHour; + if (minute == 0) { + return '$hour:00 $period'; + } + return '$hour:${minute.toString().padLeft(2, '0')}'; + } +} + +class TimelineTick { + const TimelineTick({ + required this.minutesSinceMidnight, + required this.y, + required this.label, + required this.major, + }); + + final int minutesSinceMidnight; + final double y; + final String label; + final bool major; } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart index 8c611a7..51449eb 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -1,18 +1,70 @@ import 'package:flutter/material.dart'; import '../../models/today_screen_models.dart'; +import '../../theme/focus_flow_tokens.dart'; +import 'task_timeline_card.dart'; +import 'timeline_axis.dart'; +import 'timeline_geometry.dart'; class TimelineView extends StatelessWidget { - const TimelineView({required this.cards, super.key}); + const TimelineView({ + required this.cards, + required this.range, + required this.onCardSelected, + super.key, + }); final List cards; + final TimelineRangeModel range; + final ValueChanged onCardSelected; @override Widget build(BuildContext context) { - return Column( - children: [ - for (final card in cards) Text(card.title, key: ValueKey(card.id)), - ], + final geometry = TimelineGeometry( + visibleStart: TimeOfDay( + hour: range.startMinutes ~/ 60, + minute: range.startMinutes % 60, + ), + visibleEnd: TimeOfDay( + hour: range.endMinutes ~/ 60, + minute: range.endMinutes % 60, + ), + pixelsPerMinute: 2.45, + minCardHeight: 88, + ); + final contentHeight = geometry.totalHeight + 24; + return SingleChildScrollView( + child: SizedBox( + height: contentHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TimelineAxis(geometry: geometry), + const SizedBox(width: 18), + Expanded( + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill(child: TimelineGrid(geometry: geometry)), + for (final card in cards) + Positioned( + top: geometry.yForMinutesSinceMidnight(card.startMinutes), + left: FocusFlowTokens.cardHorizontalPadding, + right: 0, + height: geometry.heightForDuration( + card.endMinutes - card.startMinutes, + ), + child: TaskTimelineCard( + card: card, + onTap: () => onCardSelected(card), + ), + ), + ], + ), + ), + ], + ), + ), ); } } diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index 175e099..c13415e 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../theme/focus_flow_tokens.dart'; + class TopBar extends StatelessWidget { const TopBar({required this.dateLabel, super.key}); @@ -7,6 +9,100 @@ class TopBar extends StatelessWidget { @override Widget build(BuildContext context) { - return Text(dateLabel); + return Row( + children: [ + Text('Today', style: Theme.of(context).textTheme.headlineLarge), + const SizedBox(width: 30), + _IconButton(icon: Icons.calendar_today, onPressed: () {}), + _IconButton(icon: Icons.chevron_left, onPressed: () {}), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + dateLabel, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 17, + ), + ), + ), + _IconButton(icon: Icons.chevron_right, onPressed: () {}), + const Spacer(), + const _ModeToggle(), + const SizedBox(width: 28), + OutlinedButton.icon( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + side: const BorderSide(color: FocusFlowTokens.subtleBorder), + fixedSize: const Size(136, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + FocusFlowTokens.smallButtonRadius, + ), + ), + ), + icon: const Icon(Icons.wb_sunny_outlined), + label: const Text('Settings'), + ), + ], + ); + } +} + +class _IconButton extends StatelessWidget { + const _IconButton({required this.icon, required this.onPressed}); + + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + color: FocusFlowTokens.textPrimary, + icon: Icon(icon), + ); + } +} + +class _ModeToggle extends StatelessWidget { + const _ModeToggle(); + + @override + Widget build(BuildContext context) { + return Container( + height: 48, + width: 220, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: Row( + children: [ + Expanded( + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: FocusFlowTokens.glassPanelStrong, + borderRadius: BorderRadius.circular( + FocusFlowTokens.smallButtonRadius, + ), + border: Border.all(color: FocusFlowTokens.navBorder), + ), + child: const Text('Compact'), + ), + ), + Expanded( + child: TextButton( + onPressed: () {}, + child: const Text( + 'Normal', + style: TextStyle(color: FocusFlowTokens.textPrimary), + ), + ), + ), + ], + ), + ); } } diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 7b150cd..2473401 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -2,171 +2,133 @@ 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/scheduler_read_controller.dart'; -import 'package:focus_flow_flutter/main.dart'; -import 'package:focus_flow_flutter/widgets/backlog_pane.dart'; +import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart'; +import 'package:focus_flow_flutter/app/focus_flow_app.dart'; +import 'package:focus_flow_flutter/models/today_screen_models.dart'; +import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart'; void main() { - testWidgets('renders Today and Backlog read models', (tester) async { - await tester.pumpWidget( - FocusFlowApp(composition: DemoSchedulerComposition.seeded()), - ); - await _pumpAndSettle(tester); + testWidgets('app shell renders compact Today frame', (tester) async { + await _pumpPlanApp(tester); expect(find.text('FocusFlow'), findsOneWidget); expect(find.text('Today'), findsWidgets); - expect(find.text('Plan the day'), findsWidgets); - expect(find.text('Appointment'), findsWidgets); - - await tester.tap(find.byIcon(Icons.inbox_outlined)); - await _pumpAndSettle(tester); - - expect(find.text('Backlog'), findsWidgets); - expect(find.text('Send project update'), findsOneWidget); - expect(find.text('Sort receipts'), findsOneWidget); - }); - - testWidgets('renders compact panel from owner settings', (tester) async { - await tester.pumpWidget( - FocusFlowApp(composition: DemoSchedulerComposition.seeded()), - ); - await _pumpAndSettle(tester); - + expect(find.text('Backlog'), findsOneWidget); + expect(find.text('Projects'), findsOneWidget); + expect(find.text('Reports'), findsOneWidget); + expect(find.text('Settings'), findsWidgets); expect(find.text('Compact'), findsOneWidget); - expect(find.text('Plan the day'), findsNWidgets(2)); + expect(find.text('Normal'), findsOneWidget); + expect(find.byKey(const ValueKey('nav-today-active')), findsOneWidget); }); - testWidgets('renders Backlog empty state', (tester) async { - await tester.pumpWidget( - _testShell( - BacklogPane( - controller: _FakeReadController( - SchedulerReadEmpty(_emptyBacklog()), - ), - ), - ), - ); - - expect(find.text('Backlog is clear'), findsOneWidget); - expect( - find.text('Captured tasks will appear here when they need planning.'), - findsOneWidget, - ); - }); - - testWidgets('typed failure state can retry', (tester) async { - final controller = _FakeReadController( - SchedulerReadFailure( - failure: const ApplicationFailure( - code: ApplicationFailureCode.conflict, - ), - ), - retryState: SchedulerReadEmpty(_emptyBacklog()), - ); - - await tester.pumpWidget(_testShell(BacklogPane(controller: controller))); - - expect(find.text('Could not load Backlog'), findsOneWidget); - expect(find.text('Status: conflict'), findsOneWidget); - - await tester.tap(find.text('Retry')); - await _pumpAndSettle(tester); - - expect(controller.retryCount, 1); - expect(find.text('Backlog is clear'), findsOneWidget); - }); - - testWidgets('quick capture schedules into Today and completes', ( + testWidgets('seeded Today renders backend-backed compact mockup data', ( tester, ) async { - final composition = DemoSchedulerComposition.seeded( - includeTodayItems: false, - includeBacklogItems: false, + await _pumpPlanApp(tester); + + expect(find.text('May 20, 2025'), findsOneWidget); + expect( + find.textContaining('Next required task:', findRichText: true), + findsOneWidget, ); + expect(find.text('Clean coffee maker'), findsOneWidget); + expect(find.text('Cleaned kitchen counter'), findsNWidgets(2)); + expect(find.text('Pay bill'), findsOneWidget); + expect(find.text('Doctor appointment'), findsOneWidget); + expect(find.text('Free Slot'), findsOneWidget); + expect(find.text('Intentional rest'), findsOneWidget); + }); - await tester.pumpWidget(FocusFlowApp(composition: composition)); - await _pumpAndSettle(tester); + test( + 'visual token adapter maps seeded items to expected visual kinds', + () async { + final state = await _readSeededToday(); + final data = TodayScreenData.fromTodayState(state); - await tester.tap(find.byIcon(Icons.inbox_outlined)); - await _pumpAndSettle(tester); + expect(_kindFor(data, 'clean-coffee-maker'), TaskVisualKind.flexible); + expect(_kindFor(data, 'pay-bill'), TaskVisualKind.required); + expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment); + expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot); + expect( + _kindFor(data, 'cleaned-kitchen-counter-early'), + TaskVisualKind.completedSurprise, + ); + }, + ); - await tester.enterText( - find.byKey(const ValueKey('quick-capture-title')), - 'Water plants', - ); - await tester.tap(find.text('Capture')); - await _pumpAndSettle(tester); + test('difficulty bars map every difficulty level to five-bar fill count', () { + expect(DifficultyBars.fillCountForLevel(DifficultyLevel.notSet), 0); + expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryEasy), 1); + expect(DifficultyBars.fillCountForLevel(DifficultyLevel.easy), 2); + expect(DifficultyBars.fillCountForLevel(DifficultyLevel.medium), 3); + expect(DifficultyBars.fillCountForLevel(DifficultyLevel.hard), 4); + expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5); + }); - expect(find.text('Water plants'), findsOneWidget); - expect(composition.store.currentTasks.single.status, TaskStatus.backlog); + testWidgets('task modal opens closes and keeps action buttons inert', ( + tester, + ) async { + await _pumpPlanApp(tester); - await tester.enterText(find.byType(TextField).last, '20'); - await tester.tap(find.byTooltip('Schedule')); - await _pumpAndSettle(tester); + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); - await tester.tap(find.byIcon(Icons.today_outlined)); - await _pumpAndSettle(tester); + await tester.tap(find.byKey(const ValueKey('pay-bill'))); + await tester.pumpAndSettle(); - expect(find.text('Water plants'), findsWidgets); - expect(composition.store.currentTasks.single.status, TaskStatus.planned); + expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); + expect(find.text('Pay bill'), findsNWidgets(2)); + expect(find.textContaining('Required'), findsWidgets); + expect(find.text('Reward level 2'), findsOneWidget); + expect(find.text('Medium effort'), findsOneWidget); + expect(find.text('Done'), findsOneWidget); + expect(find.text('Push'), findsOneWidget); + expect(find.text('Move to Backlog'), findsOneWidget); + expect(find.text('Break up'), findsOneWidget); - await tester.tap(find.byTooltip('Done').first); - await _pumpAndSettle(tester); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); - expect(composition.store.currentTasks.single.status, TaskStatus.completed); + 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'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + + await tester.tap(find.byKey(const ValueKey('pay-bill'))); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(1450, 140)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect(find.text('Pay bill'), findsOneWidget); }); } -Future _pumpAndSettle(WidgetTester tester) { - return tester.pumpAndSettle( - const Duration(milliseconds: 50), - EnginePhase.sendSemanticsUpdate, - const Duration(seconds: 2), - ); +Future _pumpPlanApp(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1586, 992)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(FocusFlowApp(composition: _composition())); + await tester.pumpAndSettle(); } -Widget _testShell(Widget child) { - return MaterialApp( - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF2F6F73), - brightness: Brightness.dark, - ), - useMaterial3: true, +DemoSchedulerComposition _composition() { + return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20)); +} + +Future _readSeededToday() async { + final composition = _composition(); + final result = await composition.todayQuery.execute( + GetTodayStateRequest( + context: composition.context('test-read-today'), + date: composition.date, ), - home: Scaffold(body: child), ); + return result.requireValue; } -BacklogQueryResult _emptyBacklog() { - return BacklogQueryResult( - ownerId: 'owner-1', - settings: OwnerSettings(ownerId: 'owner-1', timeZoneId: 'UTC'), - items: const [], - ); -} - -class _FakeReadController extends UiReadController { - _FakeReadController(SchedulerReadState state, {this.retryState}) - : _state = state; - - SchedulerReadState _state; - final SchedulerReadState? retryState; - int retryCount = 0; - - @override - SchedulerReadState get state => _state; - - @override - Future load() async {} - - @override - Future retry() async { - retryCount += 1; - final next = retryState; - if (next != null) { - _state = next; - notifyListeners(); - } - } +TaskVisualKind _kindFor(TodayScreenData data, String id) { + return data.cards.singleWhere((card) => card.id == id).visualKind; }