forked from eva/focus-flow
feat(ui): complete compact today plan
This commit is contained in:
parent
b4357ae6b5
commit
b0d73f7489
29 changed files with 2159 additions and 493 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# UI Plan 1 - Compact Today Timeline Mockup
|
# 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.
|
Start with `UI_PLAN_1_SUMMARY.md`, then execute the block files in `UI_PLAN_1_EXECUTION_ORDER.md` order.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# UI Plan 1 Block 02 — App Shell and Visual System
|
# 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.
|
**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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# UI Plan 1 Block 03 — Backend-Backed Static Today Seed
|
# 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.
|
**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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# UI Plan 1 Block 04 — Compact Timeline Rail and Task Cards
|
# 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.
|
**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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# UI Plan 1 Block 05 — Task Selection Modal
|
# 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.
|
**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 analyze
|
||||||
flutter test
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# UI Plan 1 Block 06 — Testing, Validation, and Handoff
|
# 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.
|
**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.
|
- Documentation captures scope and next steps.
|
||||||
- No unrelated functionality was pulled in.
|
- No unrelated functionality was pulled in.
|
||||||
- The zip/repo is ready for the next plan: functional component wiring.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# UI Plan 1 Execution Order
|
# 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.
|
Run these files in order. All chunks are intended for `xhigh` execution.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# UI Plan 1 — Compact Today Timeline Mockup Implementation
|
# 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/`
|
**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.
|
**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.
|
**Execution mode:** Run all chunks at `xhigh`; this plan intentionally does not repeat per-chunk thinking labels.
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,21 @@ Flutter desktop app target for the FocusFlow UI work.
|
||||||
|
|
||||||
## Current Scope
|
## Current Scope
|
||||||
|
|
||||||
UI Plan 1 is limited to a compact Today timeline mockup and selected-task
|
UI Plan 1 implements a compact Today timeline mockup and selected-task modal.
|
||||||
modal. The app target already existed before UI Plan 1 Block 1, so this package
|
The app target already existed before UI Plan 1 Block 1, so this package keeps
|
||||||
keeps the existing Flutter desktop scaffolding and local scheduler dependency.
|
the existing Flutter desktop scaffolding and local scheduler dependency.
|
||||||
|
|
||||||
The current Block 1 work establishes structure and package-boundary guardrails.
|
The app launches into the compact Today screen. All visible controls are
|
||||||
The visual shell, seeded Today state, timeline cards, and modal are implemented
|
intentionally no-op except selecting a task card and closing the modal.
|
||||||
in later UI Plan 1 blocks.
|
|
||||||
|
## 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
|
## 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
|
desktop notification implementations, backup/export packages, or direct OS APIs
|
||||||
for this UI slice. `test/forbidden_imports_test.dart` enforces that boundary.
|
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
|
## Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -33,3 +51,11 @@ flutter test
|
||||||
```
|
```
|
||||||
|
|
||||||
Run these from `apps/focus_flow_flutter/`.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1 @@
|
||||||
import 'package:flutter/material.dart';
|
export 'app/focus_flow_app.dart' show FocusFlowApp;
|
||||||
|
|
||||||
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<SchedulerHome> createState() => _SchedulerHomeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SchedulerHomeState extends State<SchedulerHome> {
|
|
||||||
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',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,304 @@
|
||||||
export '../composition/demo_scheduler_composition.dart'
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
show DemoSchedulerComposition;
|
|
||||||
|
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 = <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,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
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<TodayState> createTodayController() {
|
||||||
|
return ApplicationReadController<TodayState>(
|
||||||
|
read: () {
|
||||||
|
return todayQuery.execute(
|
||||||
|
GetTodayStateRequest(
|
||||||
|
context: context('ui-read-today'),
|
||||||
|
date: date,
|
||||||
|
includeNextFlexibleItem: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
isEmpty: (state) {
|
||||||
|
return state.timelineItems.isEmpty && state.pendingNotices.isEmpty;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
UiReadController<BacklogQueryResult> createBacklogController() {
|
||||||
|
return ApplicationReadController<BacklogQueryResult>(
|
||||||
|
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<Task> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<FocusFlowHome> createState() => _FocusFlowHomeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
|
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<TimelineCardModel> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,196 +1 @@
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
export '../app/demo_scheduler_composition.dart' show DemoSchedulerComposition;
|
||||||
|
|
||||||
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<TodayState> createTodayController() {
|
|
||||||
return ApplicationReadController<TodayState>(
|
|
||||||
read: () {
|
|
||||||
return todayQuery.execute(
|
|
||||||
GetTodayStateRequest(
|
|
||||||
context: context('ui-read-today'),
|
|
||||||
date: date,
|
|
||||||
includeNextFlexibleItem: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isEmpty: (state) {
|
|
||||||
return state.timelineItems.isEmpty && state.pendingNotices.isEmpty;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
UiReadController<BacklogQueryResult> createBacklogController() {
|
|
||||||
return ApplicationReadController<BacklogQueryResult>(
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,82 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
import 'scheduler_read_controller.dart';
|
import '../models/today_screen_models.dart';
|
||||||
|
|
||||||
typedef TodayScreenController = UiReadController<TodayState>;
|
typedef TodayStateReader = Future<ApplicationResult<TodayState>> 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<void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
class RequiredBannerModel {
|
class RequiredBannerModel {
|
||||||
const RequiredBannerModel({required this.title, required this.timeText});
|
const RequiredBannerModel({required this.title, required this.timeText});
|
||||||
|
|
||||||
|
|
@ -5,15 +7,66 @@ class RequiredBannerModel {
|
||||||
final String timeText;
|
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 {
|
class TodayScreenData {
|
||||||
const TodayScreenData({
|
const TodayScreenData({
|
||||||
required this.dateLabel,
|
required this.dateLabel,
|
||||||
|
required this.timelineRange,
|
||||||
required this.cards,
|
required this.cards,
|
||||||
this.requiredBanner,
|
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<TimelineCardModel>.unmodifiable(cards),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const defaultRange = TimelineRangeModel(
|
||||||
|
startMinutes: 16 * 60,
|
||||||
|
endMinutes: 20 * 60 + 45,
|
||||||
|
);
|
||||||
|
|
||||||
final String dateLabel;
|
final String dateLabel;
|
||||||
final RequiredBannerModel? requiredBanner;
|
final RequiredBannerModel? requiredBanner;
|
||||||
|
final TimelineRangeModel timelineRange;
|
||||||
final List<TimelineCardModel> cards;
|
final List<TimelineCardModel> cards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,10 +74,164 @@ class TimelineCardModel {
|
||||||
const TimelineCardModel({
|
const TimelineCardModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
|
required this.typeLabel,
|
||||||
|
required this.subtitle,
|
||||||
required this.timeText,
|
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 id;
|
||||||
final String title;
|
final String title;
|
||||||
|
final String typeLabel;
|
||||||
|
final String subtitle;
|
||||||
final String timeText;
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,34 @@ abstract final class FocusFlowTheme {
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
colorScheme: scheme,
|
colorScheme: scheme,
|
||||||
scaffoldBackgroundColor: FocusFlowTokens.appBackground,
|
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,
|
useMaterial3: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,47 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
abstract final class FocusFlowTokens {
|
abstract final class FocusFlowTokens {
|
||||||
static const appBackground = Color(0xFF080A12);
|
static const appBackground = Color(0xFF070811);
|
||||||
static const panelBackground = Color(0xFF11131E);
|
static const appFrameBorder = Color(0xFF3B3D4E);
|
||||||
static const elevatedPanel = Color(0xFF171925);
|
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 accentMagenta = Color(0xFFFF5BA8);
|
||||||
static const textPrimary = Color(0xFFF7F2FA);
|
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 completedText = Color(0xFF8F8A99);
|
||||||
|
|
||||||
static const flexibleGreen = Color(0xFF4BE064);
|
static const flexibleGreen = Color(0xFF4BE064);
|
||||||
static const requiredPink = Color(0xFFFF4E7D);
|
static const requiredPink = Color(0xFFFF4E7D);
|
||||||
static const appointmentBlue = Color(0xFF38A8FF);
|
static const appointmentBlue = Color(0xFF38A8FF);
|
||||||
static const restPurple = Color(0xFFB05CFF);
|
static const restPurple = Color(0xFFB45CFF);
|
||||||
static const completedGray = Color(0xFF777482);
|
static const completedGray = Color(0xFF777482);
|
||||||
static const subtleBorder = Color(0xFF3A3A4A);
|
static const warningYellow = Color(0xFFFFCC66);
|
||||||
|
|
||||||
static const sidebarWidth = 250.0;
|
static const sidebarWidth = 250.0;
|
||||||
static const mainGutter = 32.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 appFrameRadius = 8.0;
|
||||||
static const navRadius = 8.0;
|
static const navRadius = 8.0;
|
||||||
static const bannerRadius = 8.0;
|
static const bannerRadius = 8.0;
|
||||||
static const cardRadius = 8.0;
|
static const cardRadius = 8.0;
|
||||||
static const modalRadius = 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,36 @@ class AppShell extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DecoratedBox(
|
return ColoredBox(
|
||||||
decoration: const BoxDecoration(color: FocusFlowTokens.appBackground),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(width: FocusFlowTokens.sidebarWidth, child: sidebar),
|
SizedBox(width: FocusFlowTokens.sidebarWidth, child: sidebar),
|
||||||
|
const VerticalDivider(
|
||||||
|
width: 1,
|
||||||
|
thickness: 1,
|
||||||
|
color: FocusFlowTokens.subtleBorder,
|
||||||
|
),
|
||||||
Expanded(child: child),
|
Expanded(child: child),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../models/today_screen_models.dart';
|
import '../models/today_screen_models.dart';
|
||||||
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
class RequiredBanner extends StatelessWidget {
|
class RequiredBanner extends StatelessWidget {
|
||||||
const RequiredBanner({this.model, super.key});
|
const RequiredBanner({this.model, super.key});
|
||||||
|
|
@ -11,8 +12,78 @@ class RequiredBanner extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final banner = model;
|
final banner = model;
|
||||||
if (banner == null) {
|
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)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,167 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
class Sidebar extends StatelessWidget {
|
class Sidebar extends StatelessWidget {
|
||||||
const Sidebar({super.key});
|
const Sidebar({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../models/today_screen_models.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 {
|
class TaskSelectionModal extends StatelessWidget {
|
||||||
const TaskSelectionModal({
|
const TaskSelectionModal({
|
||||||
|
|
@ -14,15 +17,192 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final accent = _accentFor(card.visualKind);
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
|
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(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(card.title),
|
Row(
|
||||||
IconButton(onPressed: onClose, icon: const Icon(Icons.close)),
|
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: [
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,32 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
class DifficultyBars extends StatelessWidget {
|
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 TimelineDifficultyIconToken difficulty;
|
||||||
final Color? color;
|
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) {
|
return switch (difficulty) {
|
||||||
DifficultyLevel.notSet => 0,
|
DifficultyLevel.notSet => 0,
|
||||||
DifficultyLevel.veryEasy => 1,
|
DifficultyLevel.veryEasy => 1,
|
||||||
|
|
@ -20,6 +39,55 @@ class DifficultyBars extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,72 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class RewardIcon extends StatelessWidget {
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
const RewardIcon({this.color, super.key});
|
|
||||||
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,231 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../models/today_screen_models.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 {
|
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 TimelineCardModel card;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../theme/focus_flow_tokens.dart';
|
||||||
import 'timeline_geometry.dart';
|
import 'timeline_geometry.dart';
|
||||||
|
|
||||||
class TimelineAxis extends StatelessWidget {
|
class TimelineAxis extends StatelessWidget {
|
||||||
|
|
@ -9,6 +10,91 @@ class TimelineAxis extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ class TimelineGeometry {
|
||||||
required this.visibleStart,
|
required this.visibleStart,
|
||||||
required this.visibleEnd,
|
required this.visibleEnd,
|
||||||
required this.pixelsPerMinute,
|
required this.pixelsPerMinute,
|
||||||
this.minCardHeight = 48,
|
this.minCardHeight = 72,
|
||||||
});
|
});
|
||||||
|
|
||||||
final TimeOfDay visibleStart;
|
final TimeOfDay visibleStart;
|
||||||
|
|
@ -13,13 +13,60 @@ class TimelineGeometry {
|
||||||
final double pixelsPerMinute;
|
final double pixelsPerMinute;
|
||||||
final double minCardHeight;
|
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) {
|
double yForMinutesSinceMidnight(int minutes) {
|
||||||
final start = visibleStart.hour * 60 + visibleStart.minute;
|
return (minutes - startMinutes) * pixelsPerMinute;
|
||||||
return (minutes - start) * pixelsPerMinute;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
double heightForDuration(int minutes) {
|
double heightForDuration(int minutes) {
|
||||||
final height = minutes * pixelsPerMinute;
|
final height = minutes * pixelsPerMinute;
|
||||||
return height < minCardHeight ? minCardHeight : height;
|
return height < minCardHeight ? minCardHeight : height;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<TimelineTick> 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,70 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../models/today_screen_models.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 {
|
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<TimelineCardModel> cards;
|
final List<TimelineCardModel> cards;
|
||||||
|
final TimelineRangeModel range;
|
||||||
|
final ValueChanged<TimelineCardModel> onCardSelected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
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: [
|
children: [
|
||||||
for (final card in cards) Text(card.title, key: ValueKey(card.id)),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
|
||||||
class TopBar extends StatelessWidget {
|
class TopBar extends StatelessWidget {
|
||||||
const TopBar({required this.dateLabel, super.key});
|
const TopBar({required this.dateLabel, super.key});
|
||||||
|
|
||||||
|
|
@ -7,6 +9,100 @@ class TopBar extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,171 +2,133 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
import 'package:focus_flow_flutter/controllers/scheduler_read_controller.dart';
|
import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart';
|
||||||
import 'package:focus_flow_flutter/main.dart';
|
import 'package:focus_flow_flutter/app/focus_flow_app.dart';
|
||||||
import 'package:focus_flow_flutter/widgets/backlog_pane.dart';
|
import 'package:focus_flow_flutter/models/today_screen_models.dart';
|
||||||
|
import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('renders Today and Backlog read models', (tester) async {
|
testWidgets('app shell renders compact Today frame', (tester) async {
|
||||||
await tester.pumpWidget(
|
await _pumpPlanApp(tester);
|
||||||
FocusFlowApp(composition: DemoSchedulerComposition.seeded()),
|
|
||||||
);
|
|
||||||
await _pumpAndSettle(tester);
|
|
||||||
|
|
||||||
expect(find.text('FocusFlow'), findsOneWidget);
|
expect(find.text('FocusFlow'), findsOneWidget);
|
||||||
expect(find.text('Today'), findsWidgets);
|
expect(find.text('Today'), findsWidgets);
|
||||||
expect(find.text('Plan the day'), findsWidgets);
|
expect(find.text('Backlog'), findsOneWidget);
|
||||||
expect(find.text('Appointment'), findsWidgets);
|
expect(find.text('Projects'), findsOneWidget);
|
||||||
|
expect(find.text('Reports'), findsOneWidget);
|
||||||
await tester.tap(find.byIcon(Icons.inbox_outlined));
|
expect(find.text('Settings'), findsWidgets);
|
||||||
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('Compact'), findsOneWidget);
|
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 {
|
testWidgets('seeded Today renders backend-backed compact mockup data', (
|
||||||
await tester.pumpWidget(
|
|
||||||
_testShell(
|
|
||||||
BacklogPane(
|
|
||||||
controller: _FakeReadController<BacklogQueryResult>(
|
|
||||||
SchedulerReadEmpty<BacklogQueryResult>(_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<BacklogQueryResult>(
|
|
||||||
SchedulerReadFailure<BacklogQueryResult>(
|
|
||||||
failure: const ApplicationFailure(
|
|
||||||
code: ApplicationFailureCode.conflict,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
retryState: SchedulerReadEmpty<BacklogQueryResult>(_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', (
|
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final composition = DemoSchedulerComposition.seeded(
|
await _pumpPlanApp(tester);
|
||||||
includeTodayItems: false,
|
|
||||||
includeBacklogItems: false,
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'visual token adapter maps seeded items to expected visual kinds',
|
||||||
|
() async {
|
||||||
|
final state = await _readSeededToday();
|
||||||
|
final data = TodayScreenData.fromTodayState(state);
|
||||||
|
|
||||||
|
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.pumpWidget(FocusFlowApp(composition: composition));
|
test('difficulty bars map every difficulty level to five-bar fill count', () {
|
||||||
await _pumpAndSettle(tester);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.inbox_outlined));
|
testWidgets('task modal opens closes and keeps action buttons inert', (
|
||||||
await _pumpAndSettle(tester);
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpPlanApp(tester);
|
||||||
|
|
||||||
await tester.enterText(
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
||||||
find.byKey(const ValueKey('quick-capture-title')),
|
|
||||||
'Water plants',
|
|
||||||
);
|
|
||||||
await tester.tap(find.text('Capture'));
|
|
||||||
await _pumpAndSettle(tester);
|
|
||||||
|
|
||||||
expect(find.text('Water plants'), findsOneWidget);
|
await tester.tap(find.byKey(const ValueKey('pay-bill')));
|
||||||
expect(composition.store.currentTasks.single.status, TaskStatus.backlog);
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byType(TextField).last, '20');
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
await tester.tap(find.byTooltip('Schedule'));
|
expect(find.text('Pay bill'), findsNWidgets(2));
|
||||||
await _pumpAndSettle(tester);
|
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.byIcon(Icons.today_outlined));
|
await tester.tap(find.text('Done'));
|
||||||
await _pumpAndSettle(tester);
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Water plants'), findsWidgets);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||||
expect(composition.store.currentTasks.single.status, TaskStatus.planned);
|
expect(find.text('Pay bill'), findsNWidgets(2));
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Done').first);
|
await tester.tap(find.byKey(const ValueKey('close-task-modal')));
|
||||||
await _pumpAndSettle(tester);
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(composition.store.currentTasks.single.status, TaskStatus.completed);
|
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<int> _pumpAndSettle(WidgetTester tester) {
|
Future<void> _pumpPlanApp(WidgetTester tester) async {
|
||||||
return tester.pumpAndSettle(
|
await tester.binding.setSurfaceSize(const Size(1586, 992));
|
||||||
const Duration(milliseconds: 50),
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
EnginePhase.sendSemanticsUpdate,
|
await tester.pumpWidget(FocusFlowApp(composition: _composition()));
|
||||||
const Duration(seconds: 2),
|
await tester.pumpAndSettle();
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _testShell(Widget child) {
|
DemoSchedulerComposition _composition() {
|
||||||
return MaterialApp(
|
return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20));
|
||||||
theme: ThemeData(
|
}
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: const Color(0xFF2F6F73),
|
Future<TodayState> _readSeededToday() async {
|
||||||
brightness: Brightness.dark,
|
final composition = _composition();
|
||||||
|
final result = await composition.todayQuery.execute(
|
||||||
|
GetTodayStateRequest(
|
||||||
|
context: composition.context('test-read-today'),
|
||||||
|
date: composition.date,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
|
||||||
),
|
|
||||||
home: Scaffold(body: child),
|
|
||||||
);
|
);
|
||||||
|
return result.requireValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
BacklogQueryResult _emptyBacklog() {
|
TaskVisualKind _kindFor(TodayScreenData data, String id) {
|
||||||
return BacklogQueryResult(
|
return data.cards.singleWhere((card) => card.id == id).visualKind;
|
||||||
ownerId: 'owner-1',
|
|
||||||
settings: OwnerSettings(ownerId: 'owner-1', timeZoneId: 'UTC'),
|
|
||||||
items: const [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FakeReadController<T> extends UiReadController<T> {
|
|
||||||
_FakeReadController(SchedulerReadState<T> state, {this.retryState})
|
|
||||||
: _state = state;
|
|
||||||
|
|
||||||
SchedulerReadState<T> _state;
|
|
||||||
final SchedulerReadState<T>? retryState;
|
|
||||||
int retryCount = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
SchedulerReadState<T> get state => _state;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> load() async {}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> retry() async {
|
|
||||||
retryCount += 1;
|
|
||||||
final next = retryState;
|
|
||||||
if (next != null) {
|
|
||||||
_state = next;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue