feat(ui): complete compact today plan

This commit is contained in:
Ashley Venn 2026-06-29 20:20:24 -07:00
parent b4357ae6b5
commit b0d73f7489
29 changed files with 2159 additions and 493 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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',
),
],
),
);
}
}

View file

@ -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);
}
}

View file

@ -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,
),
),
],
],
);
}
}

View file

@ -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,
);
}

View file

@ -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();
}
}

View file

@ -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';
} }

View file

@ -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,
); );
} }

View file

@ -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;
} }

View file

@ -10,13 +10,35 @@ 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: Row( child: SafeArea(
children: [ child: Padding(
SizedBox(width: FocusFlowTokens.sidebarWidth, child: sidebar), padding: const EdgeInsets.all(4),
Expanded(child: child), child: ClipRRect(
], borderRadius: BorderRadius.circular(FocusFlowTokens.appFrameRadius),
child: DecoratedBox(
decoration: BoxDecoration(
color: FocusFlowTokens.appBackground,
border: Border.all(color: FocusFlowTokens.appFrameBorder),
borderRadius: BorderRadius.circular(
FocusFlowTokens.appFrameRadius,
),
),
child: Row(
children: [
SizedBox(width: FocusFlowTokens.sidebarWidth, child: sidebar),
const VerticalDivider(
width: 1,
thickness: 1,
color: FocusFlowTokens.subtleBorder,
),
Expanded(child: child),
],
),
),
),
),
), ),
); );
} }

View file

@ -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)],
),
),
),
],
),
);
} }
} }

View file

@ -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,
),
),
),
],
),
),
),
);
} }
} }

View file

@ -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: Column( child: Container(
mainAxisSize: MainAxisSize.min, key: const ValueKey('task-selection-modal'),
width: 548,
padding: const EdgeInsets.fromLTRB(24, 22, 24, 20),
decoration: BoxDecoration(
color: FocusFlowTokens.elevatedPanel.withValues(alpha: 0.98),
borderRadius: BorderRadius.circular(FocusFlowTokens.modalRadius),
border: Border.all(color: accent.withValues(alpha: 0.86)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: accent, width: 4),
),
),
const SizedBox(width: 28),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
style: const TextStyle(
color: FocusFlowTokens.textPrimary,
fontSize: FocusFlowTokens.modalTitleSize,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'${card.typeLabel} - ${card.timeText}',
style: const TextStyle(
color: FocusFlowTokens.textMuted,
fontSize: 17,
),
),
],
),
),
IconButton(
key: const ValueKey('close-task-modal'),
onPressed: onClose,
style: IconButton.styleFrom(
backgroundColor: FocusFlowTokens.glassPanelStrong,
foregroundColor: FocusFlowTokens.textPrimary,
),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 26),
Row(
children: [
Expanded(
child: _InfoTile(
label: card.rewardLabel,
child: RewardIcon(color: FocusFlowTokens.accentMagenta),
),
),
const SizedBox(width: 28),
Expanded(
child: _InfoTile(
label: card.effortLabel,
child: DifficultyBars(
difficulty: card.difficultyIconToken,
color: FocusFlowTokens.restPurple,
),
),
),
],
),
const SizedBox(height: 30),
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 26,
mainAxisSpacing: 16,
childAspectRatio: 4.1,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: const [
_ActionButton(icon: Icons.check, label: 'Done'),
_ActionButton(icon: Icons.arrow_forward, label: 'Push'),
_ActionButton(
icon: Icons.inventory_2_outlined,
label: 'Move to Backlog',
),
_ActionButton(icon: Icons.apps, label: 'Break up'),
],
),
const SizedBox(height: 14),
const Text(
'Options apply after confirmation.',
style: TextStyle(color: FocusFlowTokens.textMuted, fontSize: 14),
),
],
),
),
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile({required this.child, required this.label});
final Widget child;
final String label;
@override
Widget build(BuildContext context) {
return Container(
height: 58,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius),
border: Border.all(color: FocusFlowTokens.subtleBorder),
),
child: Row(
children: [ children: [
Text(card.title), child,
IconButton(onPressed: onClose, icon: const Icon(Icons.close)), 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,
};
}

View file

@ -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;
} }
} }

View file

@ -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;
} }
} }

View file

@ -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,
),
};
} }
} }

View file

@ -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;
} }
} }

View file

@ -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;
} }

View file

@ -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(
children: [ visibleStart: TimeOfDay(
for (final card in cards) Text(card.title, key: ValueKey(card.id)), hour: range.startMinutes ~/ 60,
], minute: range.startMinutes % 60,
),
visibleEnd: TimeOfDay(
hour: range.endMinutes ~/ 60,
minute: range.endMinutes % 60,
),
pixelsPerMinute: 2.45,
minCardHeight: 88,
);
final contentHeight = geometry.totalHeight + 24;
return SingleChildScrollView(
child: SizedBox(
height: contentHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TimelineAxis(geometry: geometry),
const SizedBox(width: 18),
Expanded(
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(child: TimelineGrid(geometry: geometry)),
for (final card in cards)
Positioned(
top: geometry.yForMinutesSinceMidnight(card.startMinutes),
left: FocusFlowTokens.cardHorizontalPadding,
right: 0,
height: geometry.heightForDuration(
card.endMinutes - card.startMinutes,
),
child: TaskTimelineCard(
card: card,
onTap: () => onCardSelected(card),
),
),
],
),
),
],
),
),
); );
} }
} }

View file

@ -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),
),
),
),
],
),
);
} }
} }

View file

@ -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);
});
await tester.pumpWidget(FocusFlowApp(composition: composition)); test(
await _pumpAndSettle(tester); 'visual token adapter maps seeded items to expected visual kinds',
() async {
final state = await _readSeededToday();
final data = TodayScreenData.fromTodayState(state);
await tester.tap(find.byIcon(Icons.inbox_outlined)); expect(_kindFor(data, 'clean-coffee-maker'), TaskVisualKind.flexible);
await _pumpAndSettle(tester); expect(_kindFor(data, 'pay-bill'), TaskVisualKind.required);
expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment);
expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot);
expect(
_kindFor(data, 'cleaned-kitchen-counter-early'),
TaskVisualKind.completedSurprise,
);
},
);
await tester.enterText( test('difficulty bars map every difficulty level to five-bar fill count', () {
find.byKey(const ValueKey('quick-capture-title')), expect(DifficultyBars.fillCountForLevel(DifficultyLevel.notSet), 0);
'Water plants', expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryEasy), 1);
); expect(DifficultyBars.fillCountForLevel(DifficultyLevel.easy), 2);
await tester.tap(find.text('Capture')); expect(DifficultyBars.fillCountForLevel(DifficultyLevel.medium), 3);
await _pumpAndSettle(tester); expect(DifficultyBars.fillCountForLevel(DifficultyLevel.hard), 4);
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
});
expect(find.text('Water plants'), findsOneWidget); testWidgets('task modal opens closes and keeps action buttons inert', (
expect(composition.store.currentTasks.single.status, TaskStatus.backlog); tester,
) async {
await _pumpPlanApp(tester);
await tester.enterText(find.byType(TextField).last, '20'); expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
await tester.tap(find.byTooltip('Schedule'));
await _pumpAndSettle(tester);
await tester.tap(find.byIcon(Icons.today_outlined)); await tester.tap(find.byKey(const ValueKey('pay-bill')));
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));
expect(find.textContaining('Required'), findsWidgets);
expect(find.text('Reward level 2'), findsOneWidget);
expect(find.text('Medium effort'), findsOneWidget);
expect(find.text('Done'), findsOneWidget);
expect(find.text('Push'), findsOneWidget);
expect(find.text('Move to Backlog'), findsOneWidget);
expect(find.text('Break up'), findsOneWidget);
await tester.tap(find.byTooltip('Done').first); await tester.tap(find.text('Done'));
await _pumpAndSettle(tester); await tester.pumpAndSettle();
expect(composition.store.currentTasks.single.status, TaskStatus.completed); expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Pay bill'), findsNWidgets(2));
await tester.tap(find.byKey(const ValueKey('close-task-modal')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
await tester.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(1450, 140));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(find.text('Pay bill'), findsOneWidget);
}); });
} }
Future<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(
useMaterial3: true, GetTodayStateRequest(
context: composition.context('test-read-today'),
date: composition.date,
), ),
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();
}
}
} }