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