diff --git a/apps/focus_flow_flutter/lib/app/demo/demo_date_formatter.dart b/apps/focus_flow_flutter/lib/app/demo/demo_date_formatter.dart new file mode 100644 index 0000000..58d2025 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/demo/demo_date_formatter.dart @@ -0,0 +1,24 @@ +part of '../demo_scheduler_composition.dart'; + +/// Formats a civil date as a long month/day/year label. +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}'; +} + +DateTime _instant(CivilDate date, int hour, [int minute = 0]) { + return DateTime.utc(date.year, date.month, date.day, hour, minute); +} diff --git a/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart b/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart new file mode 100644 index 0000000..e5d117e --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/demo/demo_seed_tasks.dart @@ -0,0 +1,156 @@ +part of '../demo_scheduler_composition.dart'; + +List _todaySeedTasks(CivilDate date, DateTime createdAt) { + return [ + _seedTask( + 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, + ), + _seedTask( + id: 'cleaned-kitchen-counter', + title: 'Cleaned kitchen counter', + type: TaskType.flexible, + status: TaskStatus.completed, + date: date, + startHour: 8, + startMinute: 15, + endHour: 8, + endMinute: 30, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + completedAt: _instant(date, 8, 5), + createdAt: createdAt, + ), + _seedTask( + id: 'sort-mail', + title: 'Sort mail', + type: TaskType.flexible, + status: TaskStatus.planned, + date: date, + startHour: 14, + startMinute: 40, + endHour: 15, + endMinute: 20, + reward: RewardLevel.low, + difficulty: DifficultyLevel.easy, + createdAt: createdAt, + ), + _seedTask( + id: 'start-laundry', + title: 'Start laundry', + type: TaskType.flexible, + status: TaskStatus.planned, + date: date, + startHour: 15, + startMinute: 0, + endHour: 15, + endMinute: 40, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + createdAt: createdAt, + ), + _seedTask( + id: 'water-plants', + title: 'Water plants', + type: TaskType.flexible, + status: TaskStatus.planned, + date: date, + startHour: 15, + startMinute: 20, + endHour: 16, + endMinute: 0, + reward: RewardLevel.medium, + difficulty: DifficultyLevel.medium, + createdAt: createdAt, + ), + _seedTask( + 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, + ), + _seedTask( + 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, + ), + _seedTask( + 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 _seedTask({ + 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: DemoSchedulerComposition.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, + ); +} diff --git a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index a1d37f5..a4a28d5 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -7,6 +7,9 @@ import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_read_controller.dart'; import '../controllers/today_screen_controller.dart'; +part 'demo/demo_date_formatter.dart'; +part 'demo/demo_seed_tasks.dart'; + /// Wires the Flutter demo UI to in-memory scheduler application use cases. class DemoSchedulerComposition { DemoSchedulerComposition._({ @@ -46,7 +49,7 @@ class DemoSchedulerComposition { final DateTime readAt; /// Human-readable label for [date]. - String get dateLabel => formatDateLabel(date); + String get dateLabel => _formatDateLabel(date); /// Creates a deterministic seeded composition for the demo app and tests. factory DemoSchedulerComposition.seeded({ @@ -178,182 +181,4 @@ class DemoSchedulerComposition { idGenerator: SequentialIdGenerator(prefix: operationId), ); } - - /// Formats a civil date as a long month/day/year label. - static String formatDateLabel(CivilDate date) { - const months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - return '${months[date.month - 1]} ${date.day}, ${date.year}'; - } - - static List _todaySeedTasks(CivilDate date, DateTime createdAt) { - return [ - _task( - id: 'clean-coffee-maker', - title: 'Clean coffee maker', - type: TaskType.flexible, - status: TaskStatus.planned, - date: date, - startHour: 16, - startMinute: 15, - endHour: 16, - endMinute: 30, - reward: RewardLevel.medium, - difficulty: DifficultyLevel.easy, - createdAt: createdAt, - ), - _task( - id: 'cleaned-kitchen-counter', - title: 'Cleaned kitchen counter', - type: TaskType.flexible, - status: TaskStatus.completed, - date: date, - startHour: 8, - startMinute: 15, - endHour: 8, - endMinute: 30, - reward: RewardLevel.medium, - difficulty: DifficultyLevel.medium, - completedAt: _instant(date, 8, 5), - createdAt: createdAt, - ), - _task( - id: 'sort-mail', - title: 'Sort mail', - type: TaskType.flexible, - status: TaskStatus.planned, - date: date, - startHour: 14, - startMinute: 40, - endHour: 15, - endMinute: 20, - reward: RewardLevel.low, - difficulty: DifficultyLevel.easy, - createdAt: createdAt, - ), - _task( - id: 'start-laundry', - title: 'Start laundry', - type: TaskType.flexible, - status: TaskStatus.planned, - date: date, - startHour: 15, - startMinute: 0, - endHour: 15, - endMinute: 40, - reward: RewardLevel.medium, - difficulty: DifficultyLevel.medium, - createdAt: createdAt, - ), - _task( - id: 'water-plants', - title: 'Water plants', - type: TaskType.flexible, - status: TaskStatus.planned, - date: date, - startHour: 15, - startMinute: 20, - endHour: 16, - endMinute: 0, - reward: RewardLevel.medium, - difficulty: DifficultyLevel.medium, - 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, - ), - ]; - } - - static Task _task({ - required String id, - required String title, - required TaskType type, - required TaskStatus status, - required CivilDate date, - required int startHour, - required int startMinute, - required int endHour, - required int endMinute, - required RewardLevel reward, - required DifficultyLevel difficulty, - required DateTime createdAt, - DateTime? completedAt, - }) { - final start = _instant(date, startHour, startMinute); - final end = _instant(date, endHour, endMinute); - return Task( - id: id, - title: title, - projectId: projectId, - type: type, - status: status, - priority: PriorityLevel.medium, - reward: reward, - difficulty: difficulty, - durationMinutes: end.difference(start).inMinutes, - scheduledStart: start, - scheduledEnd: end, - actualStart: status == TaskStatus.completed ? start : null, - actualEnd: status == TaskStatus.completed ? end : null, - completedAt: completedAt, - createdAt: createdAt, - updatedAt: completedAt ?? createdAt, - ); - } - - static DateTime _instant(CivilDate date, int hour, [int minute = 0]) { - return DateTime.utc(date.year, date.month, date.day, hour, minute); - } } diff --git a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart index a56520d..0912db6 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -16,6 +16,12 @@ import '../widgets/timeline/timeline_view.dart'; import '../widgets/top_bar.dart'; import 'demo_scheduler_composition.dart'; +part 'home/focus_flow_home.dart'; +part 'home/focus_flow_home_state.dart'; +part 'home/plan_issue.dart'; +part 'home/today_screen_scaffold.dart'; +part 'home/today_screen_scaffold_state.dart'; + /// Root Material app for the FocusFlow desktop UI. class FocusFlowApp extends StatelessWidget { /// Creates a FocusFlow app from an application composition. @@ -34,199 +40,3 @@ class FocusFlowApp extends StatelessWidget { ); } } - -/// Stateful home screen that owns the Today controller lifecycle. -class FocusFlowHome extends StatefulWidget { - /// Creates the FocusFlow home screen from an application composition. - const FocusFlowHome({required this.composition, super.key}); - - /// Factory and controller bundle used by the home screen. - final DemoSchedulerComposition composition; - - @override - State createState() => _FocusFlowHomeState(); -} - -class _FocusFlowHomeState extends State { - late final TodayScreenController controller; - late final SchedulerCommandController commandController; - - @override - void initState() { - super.initState(); - controller = widget.composition.createTodayScreenController(); - commandController = widget.composition.createCommandController( - refreshReads: controller.load, - ); - controller.load(); - } - - @override - void dispose() { - commandController.dispose(); - controller.dispose(); - super.dispose(); - } - - Future _toggleCardCompletion(TimelineCardModel card) async { - if (!card.canToggleCompletion) { - return; - } - if (card.isCompleted) { - await commandController.uncompleteTask(taskId: card.id); - } else { - await commandController.completeTask( - taskId: card.id, - taskType: card.taskType, - ); - } - } - - @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, - onCompleteCard: _toggleCardCompletion, - onClearSelection: controller.clearSelection, - ), - TodayScreenReady(:final data) => _TodayScreenScaffold( - data: data, - selectedCard: controller.selectedCard, - onSelectCard: controller.selectCard, - onCompleteCard: _toggleCardCompletion, - 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 StatefulWidget { - const _TodayScreenScaffold({ - required this.data, - required this.selectedCard, - required this.onSelectCard, - required this.onCompleteCard, - required this.onClearSelection, - }); - - final TodayScreenData data; - final TimelineCardModel? selectedCard; - final ValueChanged onSelectCard; - final Future Function(TimelineCardModel card) onCompleteCard; - final VoidCallback onClearSelection; - - @override - State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState(); -} - -class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { - String? _timelineScrollTargetCardId; - var _timelineScrollRequest = 0; - - void _showUpcomingRequiredTask() { - final targetCardId = widget.data.requiredBanner?.timelineCardId; - if (targetCardId == null) { - return; - } - setState(() { - _timelineScrollTargetCardId = targetCardId; - _timelineScrollRequest += 1; - }); - } - - @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: widget.data.dateLabel), - if (widget.data.requiredBanner == null) - const SizedBox(height: 16) - else ...[ - const SizedBox(height: 24), - RequiredBanner( - model: widget.data.requiredBanner, - onShowUpcoming: _showUpcomingRequiredTask, - ), - const SizedBox(height: 22), - ], - Expanded( - child: TimelineView( - cards: widget.data.cards, - range: widget.data.timelineRange, - scrollTargetCardId: _timelineScrollTargetCardId, - scrollRequest: _timelineScrollRequest, - onCardSelected: widget.onSelectCard, - onCardCompleted: widget.onCompleteCard, - ), - ), - ], - ), - ), - if (widget.selectedCard != null) ...[ - Positioned.fill( - child: GestureDetector( - key: const ValueKey('modal-click-away'), - behavior: HitTestBehavior.opaque, - onTap: widget.onClearSelection, - child: const ColoredBox(color: Colors.transparent), - ), - ), - Center( - child: TaskSelectionModal( - card: widget.selectedCard!, - onClose: widget.onClearSelection, - onStatusPressed: widget.selectedCard!.canToggleCompletion - ? () => widget.onCompleteCard(widget.selectedCard!) - : null, - ), - ), - ], - ], - ); - } -} diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart new file mode 100644 index 0000000..bf4788f --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home.dart @@ -0,0 +1,13 @@ +part of '../focus_flow_app.dart'; + +/// Stateful home screen that owns the Today controller lifecycle. +class FocusFlowHome extends StatefulWidget { + /// Creates the FocusFlow home screen from an application composition. + const FocusFlowHome({required this.composition, super.key}); + + /// Factory and controller bundle used by the home screen. + final DemoSchedulerComposition composition; + + @override + State createState() => _FocusFlowHomeState(); +} diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart new file mode 100644 index 0000000..82d70e8 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -0,0 +1,76 @@ +part of '../focus_flow_app.dart'; + +class _FocusFlowHomeState extends State { + late final TodayScreenController controller; + late final SchedulerCommandController commandController; + + @override + void initState() { + super.initState(); + controller = widget.composition.createTodayScreenController(); + commandController = widget.composition.createCommandController( + refreshReads: controller.load, + ); + controller.load(); + } + + @override + void dispose() { + commandController.dispose(); + controller.dispose(); + super.dispose(); + } + + Future _toggleCardCompletion(TimelineCardModel card) async { + if (!card.canToggleCompletion) { + return; + } + if (card.isCompleted) { + await commandController.uncompleteTask(taskId: card.id); + } else { + await commandController.completeTask( + taskId: card.id, + taskType: card.taskType, + ); + } + } + + @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, + onCompleteCard: _toggleCardCompletion, + onClearSelection: controller.clearSelection, + ), + TodayScreenReady(:final data) => _TodayScreenScaffold( + data: data, + selectedCard: controller.selectedCard, + onSelectCard: controller.selectCard, + onCompleteCard: _toggleCardCompletion, + onClearSelection: controller.clearSelection, + ), + }; + }, + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/app/home/plan_issue.dart b/apps/focus_flow_flutter/lib/app/home/plan_issue.dart new file mode 100644 index 0000000..4508e4a --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/plan_issue.dart @@ -0,0 +1,14 @@ +part of '../focus_flow_app.dart'; + +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), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart new file mode 100644 index 0000000..96a4d98 --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart @@ -0,0 +1,20 @@ +part of '../focus_flow_app.dart'; + +class _TodayScreenScaffold extends StatefulWidget { + const _TodayScreenScaffold({ + required this.data, + required this.selectedCard, + required this.onSelectCard, + required this.onCompleteCard, + required this.onClearSelection, + }); + + final TodayScreenData data; + final TimelineCardModel? selectedCard; + final ValueChanged onSelectCard; + final Future Function(TimelineCardModel card) onCompleteCard; + final VoidCallback onClearSelection; + + @override + State<_TodayScreenScaffold> createState() => _TodayScreenScaffoldState(); +} diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart new file mode 100644 index 0000000..02fa25c --- /dev/null +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart @@ -0,0 +1,78 @@ +part of '../focus_flow_app.dart'; + +class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { + String? _timelineScrollTargetCardId; + var _timelineScrollRequest = 0; + + void _showUpcomingRequiredTask() { + final targetCardId = widget.data.requiredBanner?.timelineCardId; + if (targetCardId == null) { + return; + } + setState(() { + _timelineScrollTargetCardId = targetCardId; + _timelineScrollRequest += 1; + }); + } + + @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: widget.data.dateLabel), + if (widget.data.requiredBanner == null) + const SizedBox(height: 16) + else ...[ + const SizedBox(height: 24), + RequiredBanner( + model: widget.data.requiredBanner, + onShowUpcoming: _showUpcomingRequiredTask, + ), + const SizedBox(height: 22), + ], + Expanded( + child: TimelineView( + cards: widget.data.cards, + range: widget.data.timelineRange, + scrollTargetCardId: _timelineScrollTargetCardId, + scrollRequest: _timelineScrollRequest, + onCardSelected: widget.onSelectCard, + onCardCompleted: widget.onCompleteCard, + ), + ), + ], + ), + ), + if (widget.selectedCard != null) ...[ + Positioned.fill( + child: GestureDetector( + key: const ValueKey('modal-click-away'), + behavior: HitTestBehavior.opaque, + onTap: widget.onClearSelection, + child: const ColoredBox(color: Colors.transparent), + ), + ), + Center( + child: TaskSelectionModal( + card: widget.selectedCard!, + onClose: widget.onClearSelection, + onStatusPressed: widget.selectedCard!.canToggleCompletion + ? () => widget.onCompleteCard(widget.selectedCard!) + : null, + ), + ), + ], + ], + ); + } +} diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart new file mode 100644 index 0000000..2a4d532 --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_callbacks.dart @@ -0,0 +1,8 @@ +part of '../scheduler_command_controller.dart'; + +/// Builds an application operation context for a UI command operation. +typedef OperationContextFactory = + ApplicationOperationContext Function(String operationId, {DateTime? now}); + +/// Refreshes any reads that should reflect a completed command. +typedef ReadRefresh = Future Function(); diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart new file mode 100644 index 0000000..802398a --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -0,0 +1,187 @@ +part of '../scheduler_command_controller.dart'; + +/// Runs scheduler write commands and exposes command state to widgets. +class SchedulerCommandController extends ChangeNotifier { + /// Creates a command controller from scheduler use cases and UI callbacks. + SchedulerCommandController({ + required this.commands, + required this.contextFor, + required this.localDate, + required this.refreshReads, + DateTime Function()? now, + }) : now = now ?? _utcNow; + + /// Scheduler write use cases invoked by this controller. + final V1ApplicationCommandUseCases commands; + + /// Factory for command-scoped application operation contexts. + final OperationContextFactory contextFor; + + /// Local date commands should operate against. + final CivilDate localDate; + + /// Refresh callback invoked after commands that mutate read state. + final ReadRefresh refreshReads; + + /// Clock used by direct UI commands such as marking a task complete. + final DateTime Function() now; + var _sequence = 0; + SchedulerCommandState _state = const SchedulerCommandIdle(); + + /// Current command execution state. + SchedulerCommandState get state => _state; + + /// Captures [title] as a backlog task. + Future quickCaptureToBacklog(String title) async { + await _run( + label: 'Capturing task', + successMessage: 'Task captured', + refreshAfterSuccess: true, + action: (operationId) { + return commands.quickCaptureToBacklog( + context: contextFor(operationId), + title: title, + projectId: 'home', + ); + }, + ); + } + + /// Schedules a backlog task into the next available slot. + Future scheduleBacklogItem({ + required String taskId, + required int durationMinutes, + required DateTime expectedUpdatedAt, + }) async { + await _run( + label: 'Scheduling task', + successMessage: 'Task scheduled', + refreshAfterSuccess: true, + action: (operationId) { + return commands.scheduleBacklogItemToNextAvailableSlot( + context: contextFor(operationId), + localDate: localDate, + taskId: taskId, + durationMinutes: durationMinutes, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + /// Completes a planned flexible task from the Today view. + Future completeFlexibleTask({ + required String taskId, + DateTime? expectedUpdatedAt, + }) async { + await completeTask( + taskId: taskId, + taskType: TaskType.flexible, + expectedUpdatedAt: expectedUpdatedAt, + ); + } + + /// Completes a planned task from the Today timeline. + Future completeTask({ + required String taskId, + required TaskType taskType, + DateTime? expectedUpdatedAt, + }) async { + final completedAt = now(); + await _run( + label: 'Completing task', + successMessage: 'Task completed', + refreshAfterSuccess: true, + action: (operationId) { + final context = contextFor(operationId, now: completedAt); + return switch (taskType) { + TaskType.flexible => commands.completeFlexibleTask( + context: context, + localDate: localDate, + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ), + TaskType.critical || + TaskType.inflexible => commands.applyRequiredTaskAction( + context: context, + localDate: localDate, + taskId: taskId, + action: RequiredTaskAction.done, + expectedUpdatedAt: expectedUpdatedAt, + ), + TaskType.locked || + TaskType.surprise || + TaskType.freeSlot => Future.value( + ApplicationResult.failure( + ApplicationFailure( + code: ApplicationFailureCode.validation, + entityId: taskId, + detailCode: 'unsupportedTaskType', + ), + ), + ), + }; + }, + ); + } + + /// Returns a completed task to its planned state. + Future uncompleteTask({ + required String taskId, + DateTime? expectedUpdatedAt, + }) async { + final occurredAt = now(); + await _run( + label: 'Reopening task', + successMessage: 'Task marked as not done', + refreshAfterSuccess: true, + action: (operationId) { + return commands.uncompleteTask( + context: contextFor(operationId, now: occurredAt), + localDate: localDate, + taskId: taskId, + expectedUpdatedAt: expectedUpdatedAt, + ); + }, + ); + } + + Future _run({ + required String label, + required String successMessage, + required bool refreshAfterSuccess, + required Future> Function( + String operationId, + ) + action, + }) async { + final operationId = _nextOperationId(); + _setState(SchedulerCommandRunning(label)); + try { + final result = await action(operationId); + final failure = result.failure; + if (failure != null) { + _setState(SchedulerCommandFailure(failure: failure)); + return; + } + if (refreshAfterSuccess) { + await refreshReads(); + } + _setState(SchedulerCommandSuccess(successMessage)); + } on Object catch (error) { + _setState(SchedulerCommandFailure(error: error)); + } + } + + String _nextOperationId() { + _sequence += 1; + return 'ui-command-$_sequence'; + } + + void _setState(SchedulerCommandState next) { + _state = next; + notifyListeners(); + } + + static DateTime _utcNow() => DateTime.now().toUtc(); +} diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_state.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_state.dart new file mode 100644 index 0000000..8f814f0 --- /dev/null +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_state.dart @@ -0,0 +1,52 @@ +part of '../scheduler_command_controller.dart'; + +/// Base state for scheduler command execution in the UI. +sealed class SchedulerCommandState { + /// Creates a scheduler command state. + const SchedulerCommandState(); +} + +/// Indicates that no scheduler command is currently running. +class SchedulerCommandIdle extends SchedulerCommandState { + /// Creates an idle command state. + const SchedulerCommandIdle(); +} + +/// Indicates that a scheduler command is currently running. +class SchedulerCommandRunning extends SchedulerCommandState { + /// Creates a running command state with a user-facing [label]. + const SchedulerCommandRunning(this.label); + + /// User-facing description of the command in progress. + final String label; +} + +/// Indicates that a scheduler command completed successfully. +class SchedulerCommandSuccess extends SchedulerCommandState { + /// Creates a success command state with a user-facing [message]. + const SchedulerCommandSuccess(this.message); + + /// User-facing success message. + final String message; +} + +/// Indicates that a scheduler command failed. +class SchedulerCommandFailure extends SchedulerCommandState { + /// Creates a failure state from an application [failure] or unexpected [error]. + const SchedulerCommandFailure({this.failure, this.error}); + + /// Typed application failure returned by the scheduler core. + final ApplicationFailure? failure; + + /// Unexpected error thrown while running the command. + final Object? error; + + /// Stable label for display and diagnostics. + String get codeLabel { + final typed = failure; + if (typed != null) { + return typed.detailCode ?? typed.code.name; + } + return 'unexpected'; + } +} diff --git a/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart b/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart index b820393..417af9e 100644 --- a/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart @@ -4,246 +4,6 @@ library; import 'package:flutter/foundation.dart'; import 'package:scheduler_core/scheduler_core.dart'; -/// Builds an application operation context for a UI command operation. -typedef OperationContextFactory = - ApplicationOperationContext Function(String operationId, {DateTime? now}); - -/// Refreshes any reads that should reflect a completed command. -typedef ReadRefresh = Future Function(); - -/// Base state for scheduler command execution in the UI. -sealed class SchedulerCommandState { - /// Creates a scheduler command state. - const SchedulerCommandState(); -} - -/// Indicates that no scheduler command is currently running. -class SchedulerCommandIdle extends SchedulerCommandState { - /// Creates an idle command state. - const SchedulerCommandIdle(); -} - -/// Indicates that a scheduler command is currently running. -class SchedulerCommandRunning extends SchedulerCommandState { - /// Creates a running command state with a user-facing [label]. - const SchedulerCommandRunning(this.label); - - /// User-facing description of the command in progress. - final String label; -} - -/// Indicates that a scheduler command completed successfully. -class SchedulerCommandSuccess extends SchedulerCommandState { - /// Creates a success command state with a user-facing [message]. - const SchedulerCommandSuccess(this.message); - - /// User-facing success message. - final String message; -} - -/// Indicates that a scheduler command failed. -class SchedulerCommandFailure extends SchedulerCommandState { - /// Creates a failure state from an application [failure] or unexpected [error]. - const SchedulerCommandFailure({this.failure, this.error}); - - /// Typed application failure returned by the scheduler core. - final ApplicationFailure? failure; - - /// Unexpected error thrown while running the command. - final Object? error; - - /// Stable label for display and diagnostics. - String get codeLabel { - final typed = failure; - if (typed != null) { - return typed.detailCode ?? typed.code.name; - } - return 'unexpected'; - } -} - -/// Runs scheduler write commands and exposes command state to widgets. -class SchedulerCommandController extends ChangeNotifier { - /// Creates a command controller from scheduler use cases and UI callbacks. - SchedulerCommandController({ - required this.commands, - required this.contextFor, - required this.localDate, - required this.refreshReads, - DateTime Function()? now, - }) : now = now ?? _utcNow; - - /// Scheduler write use cases invoked by this controller. - final V1ApplicationCommandUseCases commands; - - /// Factory for command-scoped application operation contexts. - final OperationContextFactory contextFor; - - /// Local date commands should operate against. - final CivilDate localDate; - - /// Refresh callback invoked after commands that mutate read state. - final ReadRefresh refreshReads; - - /// Clock used by direct UI commands such as marking a task complete. - final DateTime Function() now; - var _sequence = 0; - SchedulerCommandState _state = const SchedulerCommandIdle(); - - /// Current command execution state. - SchedulerCommandState get state => _state; - - /// Captures [title] as a backlog task. - Future quickCaptureToBacklog(String title) async { - await _run( - label: 'Capturing task', - successMessage: 'Task captured', - refreshAfterSuccess: true, - action: (operationId) { - return commands.quickCaptureToBacklog( - context: contextFor(operationId), - title: title, - projectId: 'home', - ); - }, - ); - } - - /// Schedules a backlog task into the next available slot. - Future scheduleBacklogItem({ - required String taskId, - required int durationMinutes, - required DateTime expectedUpdatedAt, - }) async { - await _run( - label: 'Scheduling task', - successMessage: 'Task scheduled', - refreshAfterSuccess: true, - action: (operationId) { - return commands.scheduleBacklogItemToNextAvailableSlot( - context: contextFor(operationId), - localDate: localDate, - taskId: taskId, - durationMinutes: durationMinutes, - expectedUpdatedAt: expectedUpdatedAt, - ); - }, - ); - } - - /// Completes a planned flexible task from the Today view. - Future completeFlexibleTask({ - required String taskId, - DateTime? expectedUpdatedAt, - }) async { - await completeTask( - taskId: taskId, - taskType: TaskType.flexible, - expectedUpdatedAt: expectedUpdatedAt, - ); - } - - /// Completes a planned task from the Today timeline. - Future completeTask({ - required String taskId, - required TaskType taskType, - DateTime? expectedUpdatedAt, - }) async { - final completedAt = now(); - await _run( - label: 'Completing task', - successMessage: 'Task completed', - refreshAfterSuccess: true, - action: (operationId) { - final context = contextFor(operationId, now: completedAt); - return switch (taskType) { - TaskType.flexible => commands.completeFlexibleTask( - context: context, - localDate: localDate, - taskId: taskId, - expectedUpdatedAt: expectedUpdatedAt, - ), - TaskType.critical || - TaskType.inflexible => commands.applyRequiredTaskAction( - context: context, - localDate: localDate, - taskId: taskId, - action: RequiredTaskAction.done, - expectedUpdatedAt: expectedUpdatedAt, - ), - TaskType.locked || - TaskType.surprise || - TaskType.freeSlot => Future.value( - ApplicationResult.failure( - ApplicationFailure( - code: ApplicationFailureCode.validation, - entityId: taskId, - detailCode: 'unsupportedTaskType', - ), - ), - ), - }; - }, - ); - } - - /// Returns a completed task to its planned state. - Future uncompleteTask({ - required String taskId, - DateTime? expectedUpdatedAt, - }) async { - final occurredAt = now(); - await _run( - label: 'Reopening task', - successMessage: 'Task marked as not done', - refreshAfterSuccess: true, - action: (operationId) { - return commands.uncompleteTask( - context: contextFor(operationId, now: occurredAt), - localDate: localDate, - taskId: taskId, - expectedUpdatedAt: expectedUpdatedAt, - ); - }, - ); - } - - Future _run({ - required String label, - required String successMessage, - required bool refreshAfterSuccess, - required Future> Function( - String operationId, - ) - action, - }) async { - final operationId = _nextOperationId(); - _setState(SchedulerCommandRunning(label)); - try { - final result = await action(operationId); - final failure = result.failure; - if (failure != null) { - _setState(SchedulerCommandFailure(failure: failure)); - return; - } - if (refreshAfterSuccess) { - await refreshReads(); - } - _setState(SchedulerCommandSuccess(successMessage)); - } on Object catch (error) { - _setState(SchedulerCommandFailure(error: error)); - } - } - - String _nextOperationId() { - _sequence += 1; - return 'ui-command-$_sequence'; - } - - void _setState(SchedulerCommandState next) { - _state = next; - notifyListeners(); - } - - static DateTime _utcNow() => DateTime.now().toUtc(); -} +part 'command/scheduler_command_callbacks.dart'; +part 'command/scheduler_command_controller_impl.dart'; +part 'command/scheduler_command_state.dart'; diff --git a/apps/focus_flow_flutter/lib/models/today/required_banner_model.dart b/apps/focus_flow_flutter/lib/models/today/required_banner_model.dart new file mode 100644 index 0000000..53ffc08 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/required_banner_model.dart @@ -0,0 +1,20 @@ +part of '../today_screen_models.dart'; + +/// Presentation model for the next-required-task banner. +class RequiredBannerModel { + /// Creates a required banner model. + const RequiredBannerModel({ + required this.timelineCardId, + required this.title, + required this.timeText, + }); + + /// Timeline card id linked to the highlighted required task. + final String timelineCardId; + + /// Title of the next required task. + final String title; + + /// Display-ready time for the next required task. + final String timeText; +} diff --git a/apps/focus_flow_flutter/lib/models/today/task_visual_kind.dart b/apps/focus_flow_flutter/lib/models/today/task_visual_kind.dart new file mode 100644 index 0000000..8a8a6f9 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/task_visual_kind.dart @@ -0,0 +1,19 @@ +part of '../today_screen_models.dart'; + +/// Visual category used to style timeline cards. +enum TaskVisualKind { + /// Flexible planned work. + flexible, + + /// Critical required task. + required, + + /// Inflexible appointment-style task. + appointment, + + /// Intentional free time. + freeSlot, + + /// Completed surprise task. + completedSurprise, +} diff --git a/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart new file mode 100644 index 0000000..722542e --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/time/time_string_formatter.dart @@ -0,0 +1,37 @@ +part of '../../today_screen_models.dart'; + +String _dateLabel(CivilDate date) { + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + return '${months[date.month - 1]} ${date.day}, ${date.year}'; +} + +int _minutesSinceMidnight(DateTime? value) { + if (value == null) { + return 0; + } + return value.hour * 60 + value.minute; +} + +String _formatTime(DateTime? value) { + if (value == null) { + return ''; + } + final period = value.hour >= 12 ? 'PM' : 'AM'; + final rawHour = value.hour % 12; + final hour = rawHour == 0 ? 12 : rawHour; + final minute = value.minute.toString().padLeft(2, '0'); + return '$hour:$minute $period'; +} diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart new file mode 100644 index 0000000..4aa6d82 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/timeline_card_model.dart @@ -0,0 +1,144 @@ +part of '../today_screen_models.dart'; + +/// Presentation model for a single compact timeline card. +class TimelineCardModel { + /// Creates a timeline card model. + 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.taskType, + required this.visualKind, + required this.rewardIconToken, + required this.difficultyIconToken, + required this.isCompleted, + required this.isSelectable, + required this.showsQuickActions, + this.completedTimeText, + this.durationMinutes, + }); + + /// Maps a scheduler timeline item into card presentation data. + 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)}'; + final completedAt = item.item.completedAt ?? item.item.end ?? item.end; + return TimelineCardModel( + id: item.id, + title: title, + typeLabel: _typeLabelFor(item.taskType, visualKind), + subtitle: _subtitleFor(item, visualKind, timeText), + timeText: timeText, + startMinutes: _minutesSinceMidnight(start), + endMinutes: _minutesSinceMidnight(end), + taskType: item.taskType, + visualKind: visualKind, + rewardIconToken: item.item.rewardIconToken, + difficultyIconToken: item.item.difficultyIconToken, + durationMinutes: duration, + completedTimeText: isCompleted ? _formatTime(completedAt) : null, + isCompleted: isCompleted, + isSelectable: true, + showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot, + ); + } + + /// Stable card identifier. + final String id; + + /// Primary task title shown on the card. + final String title; + + /// Short task type label. + final String typeLabel; + + /// Secondary line shown under the title. + final String subtitle; + + /// Display-ready time or duration text. + final String timeText; + + /// Display-ready completion time, if the task has been completed. + final String? completedTimeText; + + /// Card start position in minutes since midnight. + final int startMinutes; + + /// Card end position in minutes since midnight. + final int endMinutes; + + /// Optional task duration in minutes. + final int? durationMinutes; + + /// Source task scheduling behavior type. + final TaskType taskType; + + /// Visual category used for styling. + final TaskVisualKind visualKind; + + /// Reward icon token from the scheduler core. + final TimelineRewardIconToken rewardIconToken; + + /// Difficulty icon token from the scheduler core. + final TimelineDifficultyIconToken difficultyIconToken; + + /// Whether the card represents completed work. + final bool isCompleted; + + /// Whether tapping the card should open its selection modal. + final bool isSelectable; + + /// Whether the card should show compact quick-action controls. + final bool showsQuickActions; + + /// Whether the left status control can toggle this task's completion. + bool get canToggleCompletion { + return switch (taskType) { + TaskType.flexible || TaskType.critical || TaskType.inflexible => true, + TaskType.locked || TaskType.surprise || TaskType.freeSlot => false, + }; + } + + /// Whether the left status control can complete this task. + bool get canComplete => canToggleCompletion && !isCompleted; + + /// Whether the left status control can return this task to planned. + bool get canUncomplete => canToggleCompletion && isCompleted; + + /// Accessibility and modal label for the reward icon. + 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', + }; + } + + /// Accessibility and modal label for the effort icon. + 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', + }; + } +} diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart new file mode 100644 index 0000000..3697b4a --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/timeline_item_presentation_mapper.dart @@ -0,0 +1,50 @@ +part of '../today_screen_models.dart'; + +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(TaskType taskType, TaskVisualKind visualKind) { + if (visualKind == TaskVisualKind.completedSurprise) { + return 'Completed'; + } + return switch (taskType) { + TaskType.flexible => 'Flexible', + TaskType.critical => 'Required', + TaskType.inflexible => 'Required', + TaskType.freeSlot => 'Free Slot', + TaskType.surprise => 'Surprise task', + TaskType.locked => 'Locked', + }; +} + +String _subtitleFor( + TodayTimelineItem item, + TaskVisualKind visualKind, + String timeText, +) { + if (visualKind == TaskVisualKind.freeSlot) { + return 'Intentional rest'; + } + if (item.taskStatus == TaskStatus.completed || + visualKind == TaskVisualKind.completedSurprise) { + return 'Completed at: ' + '${_formatTime(item.item.completedAt ?? item.item.end ?? item.end)}'; + } + if (item.taskType == TaskType.flexible && item.item.durationMinutes != null) { + return '${item.item.durationMinutes} min'; + } + return timeText; +} diff --git a/apps/focus_flow_flutter/lib/models/today/timeline_range_model.dart b/apps/focus_flow_flutter/lib/models/today/timeline_range_model.dart new file mode 100644 index 0000000..2b48a20 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/timeline_range_model.dart @@ -0,0 +1,16 @@ +part of '../today_screen_models.dart'; + +/// Visible timeline range expressed as minutes since midnight. +class TimelineRangeModel { + /// Creates a timeline range model. + const TimelineRangeModel({ + required this.startMinutes, + required this.endMinutes, + }); + + /// First visible minute from midnight. + final int startMinutes; + + /// Last visible minute from midnight. + final int endMinutes; +} diff --git a/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart new file mode 100644 index 0000000..4e35ca8 --- /dev/null +++ b/apps/focus_flow_flutter/lib/models/today/today_screen_data.dart @@ -0,0 +1,59 @@ +part of '../today_screen_models.dart'; + +/// Presentation model for the compact Today screen. +class TodayScreenData { + /// Creates Today screen data. + const TodayScreenData({ + required this.dateLabel, + required this.timelineRange, + required this.cards, + this.requiredBanner, + }); + + /// Creates an empty Today screen model for [dateLabel]. + factory TodayScreenData.empty({required String dateLabel}) { + return TodayScreenData( + dateLabel: dateLabel, + timelineRange: defaultRange, + cards: const [], + ); + } + + /// Maps scheduler core [TodayState] into UI presentation data. + 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( + timelineCardId: nextRequired.id, + title: nextRequired.item.displayTitle, + timeText: _formatTime(nextRequired.start), + ), + cards: List.unmodifiable(cards), + ); + } + + /// Default visible range for the compact timeline. + static const defaultRange = TimelineRangeModel( + startMinutes: 0, + endMinutes: 24 * 60, + ); + + /// Display-ready date label. + final String dateLabel; + + /// Optional next-required-task banner model. + final RequiredBannerModel? requiredBanner; + + /// Visible timeline range. + final TimelineRangeModel timelineRange; + + /// Timeline cards to render. + final List cards; +} diff --git a/apps/focus_flow_flutter/lib/models/today_screen_models.dart b/apps/focus_flow_flutter/lib/models/today_screen_models.dart index fd18048..ac68893 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -3,340 +3,10 @@ library; import 'package:scheduler_core/scheduler_core.dart'; -/// Presentation model for the next-required-task banner. -class RequiredBannerModel { - /// Creates a required banner model. - const RequiredBannerModel({ - required this.timelineCardId, - required this.title, - required this.timeText, - }); - - /// Timeline card id linked to the highlighted required task. - final String timelineCardId; - - /// Title of the next required task. - final String title; - - /// Display-ready time for the next required task. - final String timeText; -} - -/// Visible timeline range expressed as minutes since midnight. -class TimelineRangeModel { - /// Creates a timeline range model. - const TimelineRangeModel({ - required this.startMinutes, - required this.endMinutes, - }); - - /// First visible minute from midnight. - final int startMinutes; - - /// Last visible minute from midnight. - final int endMinutes; -} - -/// Visual category used to style timeline cards. -enum TaskVisualKind { - /// Flexible planned work. - flexible, - - /// Critical required task. - required, - - /// Inflexible appointment-style task. - appointment, - - /// Intentional free time. - freeSlot, - - /// Completed surprise task. - completedSurprise, -} - -/// Presentation model for the compact Today screen. -class TodayScreenData { - /// Creates Today screen data. - const TodayScreenData({ - required this.dateLabel, - required this.timelineRange, - required this.cards, - this.requiredBanner, - }); - - /// Creates an empty Today screen model for [dateLabel]. - factory TodayScreenData.empty({required String dateLabel}) { - return TodayScreenData( - dateLabel: dateLabel, - timelineRange: defaultRange, - cards: const [], - ); - } - - /// Maps scheduler core [TodayState] into UI presentation data. - 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( - timelineCardId: nextRequired.id, - title: nextRequired.item.displayTitle, - timeText: _formatTime(nextRequired.start), - ), - cards: List.unmodifiable(cards), - ); - } - - /// Default visible range for the compact timeline. - static const defaultRange = TimelineRangeModel( - startMinutes: 0, - endMinutes: 24 * 60, - ); - - /// Display-ready date label. - final String dateLabel; - - /// Optional next-required-task banner model. - final RequiredBannerModel? requiredBanner; - - /// Visible timeline range. - final TimelineRangeModel timelineRange; - - /// Timeline cards to render. - final List cards; -} - -/// Presentation model for a single compact timeline card. -class TimelineCardModel { - /// Creates a timeline card model. - 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.taskType, - required this.visualKind, - required this.rewardIconToken, - required this.difficultyIconToken, - required this.isCompleted, - required this.isSelectable, - required this.showsQuickActions, - this.completedTimeText, - this.durationMinutes, - }); - - /// Maps a scheduler timeline item into card presentation data. - 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)}'; - final completedAt = item.item.completedAt ?? item.item.end ?? item.end; - return TimelineCardModel( - id: item.id, - title: title, - typeLabel: _typeLabelFor(item.taskType, visualKind), - subtitle: _subtitleFor(item, visualKind, timeText), - timeText: timeText, - startMinutes: _minutesSinceMidnight(start), - endMinutes: _minutesSinceMidnight(end), - taskType: item.taskType, - visualKind: visualKind, - rewardIconToken: item.item.rewardIconToken, - difficultyIconToken: item.item.difficultyIconToken, - durationMinutes: duration, - completedTimeText: isCompleted ? _formatTime(completedAt) : null, - isCompleted: isCompleted, - isSelectable: true, - showsQuickActions: !isCompleted && visualKind != TaskVisualKind.freeSlot, - ); - } - - /// Stable card identifier. - final String id; - - /// Primary task title shown on the card. - final String title; - - /// Short task type label. - final String typeLabel; - - /// Secondary line shown under the title. - final String subtitle; - - /// Display-ready time or duration text. - final String timeText; - - /// Display-ready completion time, if the task has been completed. - final String? completedTimeText; - - /// Card start position in minutes since midnight. - final int startMinutes; - - /// Card end position in minutes since midnight. - final int endMinutes; - - /// Optional task duration in minutes. - final int? durationMinutes; - - /// Source task scheduling behavior type. - final TaskType taskType; - - /// Visual category used for styling. - final TaskVisualKind visualKind; - - /// Reward icon token from the scheduler core. - final TimelineRewardIconToken rewardIconToken; - - /// Difficulty icon token from the scheduler core. - final TimelineDifficultyIconToken difficultyIconToken; - - /// Whether the card represents completed work. - final bool isCompleted; - - /// Whether tapping the card should open its selection modal. - final bool isSelectable; - - /// Whether the card should show compact quick-action controls. - final bool showsQuickActions; - - /// Whether the left status control can toggle this task's completion. - bool get canToggleCompletion { - return switch (taskType) { - TaskType.flexible || TaskType.critical || TaskType.inflexible => true, - TaskType.locked || TaskType.surprise || TaskType.freeSlot => false, - }; - } - - /// Whether the left status control can complete this task. - bool get canComplete => canToggleCompletion && !isCompleted; - - /// Whether the left status control can return this task to planned. - bool get canUncomplete => canToggleCompletion && isCompleted; - - /// Accessibility and modal label for the reward icon. - 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', - }; - } - - /// Accessibility and modal label for the effort icon. - 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(TaskType taskType, TaskVisualKind visualKind) { - if (visualKind == TaskVisualKind.completedSurprise) { - return 'Completed'; - } - return switch (taskType) { - TaskType.flexible => 'Flexible', - TaskType.critical => 'Required', - TaskType.inflexible => 'Required', - TaskType.freeSlot => 'Free Slot', - TaskType.surprise => 'Surprise task', - TaskType.locked => 'Locked', - }; -} - -String _subtitleFor( - TodayTimelineItem item, - TaskVisualKind visualKind, - String timeText, -) { - if (visualKind == TaskVisualKind.freeSlot) { - return 'Intentional rest'; - } - if (item.taskStatus == TaskStatus.completed || - visualKind == TaskVisualKind.completedSurprise) { - return 'Completed at: ' - '${_formatTime(item.item.completedAt ?? item.item.end ?? item.end)}'; - } - 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'; -} +part 'today/required_banner_model.dart'; +part 'today/task_visual_kind.dart'; +part 'today/time/time_string_formatter.dart'; +part 'today/timeline_card_model.dart'; +part 'today/timeline_item_presentation_mapper.dart'; +part 'today/timeline_range_model.dart'; +part 'today/today_screen_data.dart'; diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart new file mode 100644 index 0000000..7e75afd --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/action_button.dart @@ -0,0 +1,32 @@ +part of '../task_selection_modal.dart'; + +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, + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/header_metadata.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/header_metadata.dart new file mode 100644 index 0000000..2a5c5c2 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/header_metadata.dart @@ -0,0 +1,35 @@ +part of '../task_selection_modal.dart'; + +class _HeaderMetadata extends StatelessWidget { + const _HeaderMetadata({required this.card}); + + final TimelineCardModel card; + + @override + Widget build(BuildContext context) { + const metadataStyle = TextStyle( + color: FocusFlowTokens.textMuted, + fontSize: 17, + ); + if (!card.isCompleted) { + return Text('${card.typeLabel} - ${card.timeText}', style: metadataStyle); + } + final completedTime = card.completedTimeText; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + completedTime == null || completedTime.isEmpty + ? 'Completed' + : 'Completed - $completedTime', + style: metadataStyle, + ), + const SizedBox(height: 3), + Text( + 'Planned: ${card.timeText}', + style: metadataStyle.copyWith(fontSize: 15), + ), + ], + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart new file mode 100644 index 0000000..9314e7a --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/info_tile.dart @@ -0,0 +1,38 @@ +part of '../task_selection_modal.dart'; + +class _InfoTile extends StatelessWidget { + const _InfoTile({required this.child, required this.label}); + + final Widget child; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + height: 58, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), + border: Border.all(color: FocusFlowTokens.subtleBorder), + ), + child: Row( + children: [ + child, + const SizedBox(width: 18), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_accent.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_accent.dart new file mode 100644 index 0000000..29e7c5e --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_accent.dart @@ -0,0 +1,11 @@ +part of '../task_selection_modal.dart'; + +Color _accentFor(TaskVisualKind kind) { + return switch (kind) { + TaskVisualKind.flexible => FocusFlowTokens.flexibleGreen, + TaskVisualKind.required => FocusFlowTokens.requiredPink, + TaskVisualKind.appointment => FocusFlowTokens.appointmentBlue, + TaskVisualKind.freeSlot => FocusFlowTokens.restPurple, + TaskVisualKind.completedSurprise => FocusFlowTokens.completedGray, + }; +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart new file mode 100644 index 0000000..78b6a03 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/modal_status_button.dart @@ -0,0 +1,54 @@ +part of '../task_selection_modal.dart'; + +class _ModalStatusButton extends StatelessWidget { + const _ModalStatusButton({ + required this.card, + required this.color, + required this.onPressed, + }); + + final TimelineCardModel card; + final Color color; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final ring = Container( + key: const ValueKey('task-modal-status'), + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: card.isCompleted ? color : Colors.transparent, + border: Border.all(color: color, width: 4), + ), + child: card.isCompleted + ? const Icon( + Icons.check, + color: FocusFlowTokens.appBackground, + size: 26, + ) + : null, + ); + if (onPressed == null) { + return ring; + } + return Tooltip( + message: card.isCompleted ? 'Mark not done' : 'Mark complete', + child: Semantics( + button: true, + label: card.isCompleted + ? 'Mark ${card.title} not done' + : 'Mark ${card.title} complete', + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: ring, + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart index df6c5fb..b26aee9 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -8,6 +8,12 @@ import '../theme/focus_flow_tokens.dart'; import 'timeline/difficulty_bars.dart'; import 'timeline/reward_icon.dart'; +part 'task_selection/action_button.dart'; +part 'task_selection/header_metadata.dart'; +part 'task_selection/info_tile.dart'; +part 'task_selection/modal_accent.dart'; +part 'task_selection/modal_status_button.dart'; + /// Modal shown when a selectable timeline task card is opened. class TaskSelectionModal extends StatelessWidget { /// Creates a task selection modal for [card]. @@ -131,168 +137,3 @@ class TaskSelectionModal extends StatelessWidget { ); } } - -class _ModalStatusButton extends StatelessWidget { - const _ModalStatusButton({ - required this.card, - required this.color, - required this.onPressed, - }); - - final TimelineCardModel card; - final Color color; - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - final ring = Container( - key: const ValueKey('task-modal-status'), - width: 44, - height: 44, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: card.isCompleted ? color : Colors.transparent, - border: Border.all(color: color, width: 4), - ), - child: card.isCompleted - ? const Icon( - Icons.check, - color: FocusFlowTokens.appBackground, - size: 26, - ) - : null, - ); - if (onPressed == null) { - return ring; - } - return Tooltip( - message: card.isCompleted ? 'Mark not done' : 'Mark complete', - child: Semantics( - button: true, - label: card.isCompleted - ? 'Mark ${card.title} not done' - : 'Mark ${card.title} complete', - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onPressed, - child: ring, - ), - ), - ), - ); - } -} - -class _HeaderMetadata extends StatelessWidget { - const _HeaderMetadata({required this.card}); - - final TimelineCardModel card; - - @override - Widget build(BuildContext context) { - const metadataStyle = TextStyle( - color: FocusFlowTokens.textMuted, - fontSize: 17, - ); - if (!card.isCompleted) { - return Text('${card.typeLabel} - ${card.timeText}', style: metadataStyle); - } - final completedTime = card.completedTimeText; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - completedTime == null || completedTime.isEmpty - ? 'Completed' - : 'Completed - $completedTime', - style: metadataStyle, - ), - const SizedBox(height: 3), - Text( - 'Planned: ${card.timeText}', - style: metadataStyle.copyWith(fontSize: 15), - ), - ], - ); - } -} - -class _InfoTile extends StatelessWidget { - const _InfoTile({required this.child, required this.label}); - - final Widget child; - final String label; - - @override - Widget build(BuildContext context) { - return Container( - height: 58, - padding: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(FocusFlowTokens.smallButtonRadius), - border: Border.all(color: FocusFlowTokens.subtleBorder), - ), - child: Row( - children: [ - child, - const SizedBox(width: 18), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: 17, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - ); - } -} - -class _ActionButton extends StatelessWidget { - const _ActionButton({required this.icon, required this.label}); - - final IconData icon; - final String label; - - @override - Widget build(BuildContext context) { - return OutlinedButton.icon( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: FocusFlowTokens.textPrimary, - side: const BorderSide(color: FocusFlowTokens.subtleBorder), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - FocusFlowTokens.smallButtonRadius, - ), - ), - ), - icon: Icon(icon), - label: Text( - label, - style: const TextStyle( - fontSize: FocusFlowTokens.modalButtonSize, - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -Color _accentFor(TaskVisualKind kind) { - return switch (kind) { - TaskVisualKind.flexible => FocusFlowTokens.flexibleGreen, - TaskVisualKind.required => FocusFlowTokens.requiredPink, - TaskVisualKind.appointment => FocusFlowTokens.appointmentBlue, - TaskVisualKind.freeSlot => FocusFlowTokens.restPurple, - TaskVisualKind.completedSurprise => FocusFlowTokens.completedGray, - }; -} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid.dart b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid.dart new file mode 100644 index 0000000..748c9b3 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid.dart @@ -0,0 +1,21 @@ +part of '../timeline_axis.dart'; + +/// Background grid aligned to timeline tick marks. +class TimelineGrid extends StatelessWidget { + /// Creates a timeline grid for [geometry]. + const TimelineGrid({required this.geometry, this.topInset = 12, super.key}); + + /// Geometry used to position grid lines. + final TimelineGeometry geometry; + + /// Vertical inset before the first timeline tick. + final double topInset; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _TimelineGridPainter(geometry: geometry, topInset: topInset), + size: Size.infinite, + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid_painter.dart b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid_painter.dart new file mode 100644 index 0000000..f9b31e1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/axis/timeline_grid_painter.dart @@ -0,0 +1,30 @@ +part of '../timeline_axis.dart'; + +class _TimelineGridPainter extends CustomPainter { + const _TimelineGridPainter({required this.geometry, required this.topInset}); + + final TimelineGeometry geometry; + final double topInset; + + @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 + topInset), + Offset(size.width, tick.y + topInset), + paint + ..color = tick.major + ? FocusFlowTokens.gridLine.withValues(alpha: 0.72) + : FocusFlowTokens.gridLine, + ); + } + } + + @override + bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) { + return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_colors.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_colors.dart new file mode 100644 index 0000000..98447b1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_colors.dart @@ -0,0 +1,43 @@ +part of '../task_timeline_card.dart'; + +class _CardColors { + const _CardColors({ + required this.accent, + required this.background, + required this.reward, + }); + + final Color accent; + final Color background; + final Color reward; + + factory _CardColors.forKind(TaskVisualKind kind) { + return switch (kind) { + TaskVisualKind.flexible => const _CardColors( + accent: FocusFlowTokens.flexibleGreen, + background: Color(0xCC021A0C), + reward: FocusFlowTokens.flexibleGreen, + ), + TaskVisualKind.required => const _CardColors( + accent: FocusFlowTokens.requiredPink, + background: Color(0xCC230814), + reward: FocusFlowTokens.requiredPink, + ), + TaskVisualKind.appointment => const _CardColors( + accent: FocusFlowTokens.appointmentBlue, + background: Color(0xCC031429), + reward: FocusFlowTokens.appointmentBlue, + ), + TaskVisualKind.freeSlot => const _CardColors( + accent: FocusFlowTokens.restPurple, + background: Color(0xCC210A33), + reward: FocusFlowTokens.restPurple, + ), + TaskVisualKind.completedSurprise => const _CardColors( + accent: FocusFlowTokens.completedGray, + background: Color(0xAA1C1D25), + reward: FocusFlowTokens.restPurple, + ), + }; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart new file mode 100644 index 0000000..2fa9a45 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_metrics.dart @@ -0,0 +1,77 @@ +part of '../task_timeline_card.dart'; + +class _CardMetrics { + const _CardMetrics({required this.scale, required this.height}); + + static const _referenceWidth = 640.0; + static const _referenceHeight = 88.0; + static const _freeSlotReferenceHeight = 108.0; + static const _compactHeightThreshold = 56.0; + static const _minimumScale = 0.16; + + factory _CardMetrics.fromConstraints( + BoxConstraints constraints, { + required TaskVisualKind kind, + }) { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : _referenceWidth; + final height = constraints.hasBoundedHeight + ? constraints.maxHeight + : _referenceHeight; + final widthScale = width / _referenceWidth; + final heightScale = + height / + (kind == TaskVisualKind.freeSlot + ? _freeSlotReferenceHeight + : _referenceHeight); + final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0); + return _CardMetrics(scale: scale.toDouble(), height: height); + } + + final double scale; + final double height; + + bool get isCompact => height < _compactHeightThreshold; + + double get cardRadius => + math.min(FocusFlowTokens.cardRadius * scale, height / 2); + double get borderWidth => math.max(0.75, 1.2 * scale); + double get shadowBlur => isCompact ? 0 : 18 * scale; + double get shadowSpread => isCompact ? 0 : scale; + double get statusRingSize { + final availableHeight = math.max(8, height - padding.vertical); + return math.min(24, availableHeight * 0.78).toDouble(); + } + + double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08); + double get statusIconSize => statusRingSize * 0.58; + double get statusGap => isCompact ? 7 : 22 * scale; + double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble(); + double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble(); + double get actionGap => math.max(3, 5 * scale); + double get actionsWidth => actionButtonSize * 4 + actionGap; + double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale); + double get indicatorGap => math.max(3, 5 * scale); + double get rewardIconSize => 14; + double get titleSize => 10; + double get metaSize => 7; + double get titleMetaGap => 4 * scale; + double get freeSlotTimeGap => 10 * scale; + double get compactTextGap => math.max(3, 8 * scale); + double get indicatorWidth => + rewardIconSize + indicatorGap + difficultySize.width; + double get expandedTrailingReserve => + indicatorWidth + math.max(8, 12 * scale); + double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale); + Size get difficultySize => Size(26, 14); + EdgeInsets get padding { + if (isCompact) { + return EdgeInsets.symmetric( + horizontal: math.max(4, 8 * scale), + vertical: math.max(1, 3 * scale), + ); + } + return EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_text.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_text.dart new file mode 100644 index 0000000..8a579c2 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/card_text.dart @@ -0,0 +1,79 @@ +part of '../task_timeline_card.dart'; + +class _CardText extends StatelessWidget { + const _CardText({ + required this.card, + required this.color, + required this.metrics, + }); + + final TimelineCardModel card; + final Color color; + final _CardMetrics metrics; + + @override + Widget build(BuildContext context) { + final titleStyle = TextStyle( + color: card.isCompleted + ? FocusFlowTokens.completedText + : FocusFlowTokens.textPrimary, + fontSize: metrics.titleSize, + fontWeight: FontWeight.w800, + decoration: card.isCompleted ? TextDecoration.lineThrough : null, + decorationColor: FocusFlowTokens.completedText, + decorationThickness: 2 * metrics.scale, + ); + return LayoutBuilder( + builder: (context, constraints) { + final textBlock = Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + SizedBox(height: metrics.titleMetaGap), + Text( + card.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: card.isCompleted ? FocusFlowTokens.completedText : color, + fontSize: metrics.metaSize, + fontStyle: card.visualKind == TaskVisualKind.freeSlot + ? FontStyle.italic + : FontStyle.normal, + fontWeight: card.visualKind == TaskVisualKind.flexible + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + if (card.visualKind == TaskVisualKind.freeSlot) ...[ + SizedBox(height: metrics.freeSlotTimeGap), + Text( + card.timeText, + style: TextStyle( + color: color, + fontSize: metrics.metaSize, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ); + if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { + return textBlock; + } + return FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: SizedBox(width: constraints.maxWidth, child: textBlock), + ); + }, + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart new file mode 100644 index 0000000..dbc906b --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/compact_card_text.dart @@ -0,0 +1,96 @@ +part of '../task_timeline_card.dart'; + +class _CompactCardText extends StatelessWidget { + const _CompactCardText({ + required this.card, + required this.color, + required this.metrics, + required this.onStatusPressed, + }); + + final TimelineCardModel card; + final Color color; + final _CardMetrics metrics; + final VoidCallback? onStatusPressed; + + @override + Widget build(BuildContext context) { + final metaText = _compactMetaText(card); + final titleStyle = TextStyle( + color: card.isCompleted + ? FocusFlowTokens.completedText + : FocusFlowTokens.textPrimary, + fontSize: metrics.titleSize, + height: 1, + fontWeight: FontWeight.w800, + decoration: card.isCompleted ? TextDecoration.lineThrough : null, + decorationColor: FocusFlowTokens.completedText, + decorationThickness: math.max(1, 2 * metrics.scale), + ); + final metaStyle = TextStyle( + color: card.isCompleted ? FocusFlowTokens.completedText : color, + fontSize: metrics.metaSize, + height: 1, + fontWeight: FontWeight.w700, + ); + return LayoutBuilder( + builder: (context, constraints) { + final line = Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (card.visualKind != TaskVisualKind.freeSlot) ...[ + _StatusRing( + card: card, + color: color, + metrics: metrics, + onPressed: card.canToggleCompletion ? onStatusPressed : null, + ), + SizedBox(width: metrics.statusGap), + ], + Flexible( + flex: 2, + fit: FlexFit.loose, + child: Text( + card.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: titleStyle, + ), + ), + SizedBox(width: metrics.compactTextGap), + Flexible( + flex: 1, + fit: FlexFit.loose, + child: Text( + metaText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: metaStyle, + ), + ), + SizedBox(width: metrics.compactTrailingReserve), + ], + ); + if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { + return line; + } + return FittedBox( + alignment: Alignment.centerLeft, + fit: BoxFit.scaleDown, + child: SizedBox(width: constraints.maxWidth, child: line), + ); + }, + ); + } + + String _compactMetaText(TimelineCardModel card) { + if (card.visualKind == TaskVisualKind.freeSlot && + card.timeText.isNotEmpty) { + return card.timeText; + } + if (card.subtitle.isNotEmpty) { + return card.subtitle; + } + return card.timeText; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart new file mode 100644 index 0000000..21740aa --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/expanded_card_content.dart @@ -0,0 +1,42 @@ +part of '../task_timeline_card.dart'; + +class _ExpandedCardContent extends StatelessWidget { + const _ExpandedCardContent({ + required this.card, + required this.colors, + required this.metrics, + required this.onStatusPressed, + }); + + final TimelineCardModel card; + final _CardColors colors; + final _CardMetrics metrics; + final VoidCallback? onStatusPressed; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + if (card.visualKind != TaskVisualKind.freeSlot) ...[ + _StatusRing( + card: card, + color: colors.accent, + metrics: metrics, + onPressed: card.canToggleCompletion ? onStatusPressed : null, + ), + SizedBox(width: metrics.statusGap), + ], + Expanded( + child: Padding( + padding: EdgeInsets.only(right: metrics.expandedTrailingReserve), + child: _CardText( + card: card, + color: colors.accent, + metrics: metrics, + ), + ), + ), + ], + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart new file mode 100644 index 0000000..983c6ea --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/no_op_icon.dart @@ -0,0 +1,28 @@ +part of '../task_timeline_card.dart'; + +class _NoOpIcon extends StatelessWidget { + const _NoOpIcon({ + required this.icon, + required this.color, + required this.metrics, + }); + + final IconData icon; + final Color color; + final _CardMetrics metrics; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: 'No-op action', + child: GestureDetector( + onTap: () {}, + behavior: HitTestBehavior.opaque, + child: SizedBox.square( + dimension: metrics.actionButtonSize, + child: Icon(icon, color: color, size: metrics.actionIconSize), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart new file mode 100644 index 0000000..b42338d --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/quick_actions.dart @@ -0,0 +1,70 @@ +part of '../task_timeline_card.dart'; + +class _QuickActions extends StatelessWidget { + const _QuickActions({ + required this.color, + required this.backgroundColor, + required this.metrics, + required this.visible, + }); + + final Color color; + final Color backgroundColor; + final _CardMetrics metrics; + final bool visible; + + @override + Widget build(BuildContext context) { + return ExcludeSemantics( + excluding: !visible, + child: IgnorePointer( + ignoring: !visible, + child: ClipRect( + child: AnimatedContainer( + width: visible ? metrics.actionsWidth : 0, + height: metrics.actionButtonSize, + decoration: BoxDecoration(color: backgroundColor), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: OverflowBox( + minWidth: metrics.actionsWidth, + maxWidth: metrics.actionsWidth, + alignment: Alignment.centerRight, + child: AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 90), + curve: Curves.easeOut, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _NoOpIcon( + icon: Icons.check, + color: color, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.arrow_forward, + color: color, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.calendar_today, + color: color, + metrics: metrics, + ), + _NoOpIcon( + icon: Icons.wb_sunny_outlined, + color: color, + metrics: metrics, + ), + SizedBox(width: metrics.actionGap), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart new file mode 100644 index 0000000..6d31f11 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/status_ring.dart @@ -0,0 +1,57 @@ +part of '../task_timeline_card.dart'; + +class _StatusRing extends StatelessWidget { + const _StatusRing({ + required this.card, + required this.color, + required this.metrics, + this.onPressed, + }); + + final TimelineCardModel card; + final Color color; + final _CardMetrics metrics; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final border = Border.all(color: color, width: metrics.statusBorderWidth); + final ring = Container( + key: ValueKey('${card.id}-status'), + width: metrics.statusRingSize, + height: metrics.statusRingSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: card.isCompleted ? color : Colors.transparent, + border: border, + ), + child: card.isCompleted + ? Icon( + Icons.check, + color: FocusFlowTokens.appBackground, + size: metrics.statusIconSize, + ) + : null, + ); + if (onPressed == null) { + return ring; + } + return Tooltip( + message: card.isCompleted ? 'Mark not done' : 'Mark complete', + child: Semantics( + button: true, + label: card.isCompleted + ? 'Mark ${card.title} not done' + : 'Mark ${card.title} complete', + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: ring, + ), + ), + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart new file mode 100644 index 0000000..4660beb --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/task_timeline_card_state.dart @@ -0,0 +1,90 @@ +part of '../task_timeline_card.dart'; + +class _TaskTimelineCardState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final card = widget.card; + final colors = _CardColors.forKind(card.visualKind); + return LayoutBuilder( + builder: (context, constraints) { + final metrics = _CardMetrics.fromConstraints( + constraints, + kind: card.visualKind, + ); + final content = SizedBox.expand( + child: Stack( + children: [ + Positioned.fill( + child: metrics.isCompact + ? _CompactCardText( + card: card, + color: colors.accent, + metrics: metrics, + onStatusPressed: widget.onStatusPressed, + ) + : _ExpandedCardContent( + card: card, + colors: colors, + metrics: metrics, + onStatusPressed: widget.onStatusPressed, + ), + ), + Positioned( + top: metrics.indicatorTop, + right: 0, + child: _TrailingControls( + card: card, + colors: colors, + metrics: metrics, + actionsVisible: card.showsQuickActions && _isHovered, + ), + ), + ], + ), + ); + return MouseRegion( + onEnter: (_) { + setState(() { + _isHovered = true; + }); + }, + onExit: (_) { + setState(() { + _isHovered = false; + }); + }, + child: Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey(card.id), + borderRadius: BorderRadius.circular(metrics.cardRadius), + onTap: widget.onTap, + child: Container( + padding: metrics.padding, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colors.background, + borderRadius: BorderRadius.circular(metrics.cardRadius), + border: Border.all( + color: colors.accent, + width: metrics.borderWidth, + ), + boxShadow: [ + BoxShadow( + color: colors.accent.withValues(alpha: 0.12), + blurRadius: metrics.shadowBlur, + spreadRadius: metrics.shadowSpread, + ), + ], + ), + child: content, + ), + ), + ), + ); + }, + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart new file mode 100644 index 0000000..a6aff39 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_card/trailing_controls.dart @@ -0,0 +1,38 @@ +part of '../task_timeline_card.dart'; + +class _TrailingControls extends StatelessWidget { + const _TrailingControls({ + required this.card, + required this.colors, + required this.metrics, + required this.actionsVisible, + }); + + final TimelineCardModel card; + final _CardColors colors; + final _CardMetrics metrics; + final bool actionsVisible; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (card.showsQuickActions) + _QuickActions( + color: colors.accent, + backgroundColor: colors.background.withValues(alpha: 1), + metrics: metrics, + visible: actionsVisible, + ), + RewardIcon(color: colors.reward, size: metrics.rewardIconSize), + SizedBox(width: metrics.indicatorGap), + DifficultyBars( + difficulty: card.difficultyIconToken, + color: colors.reward, + size: metrics.difficultySize, + ), + ], + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart index 46053e2..96b48bf 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/task_timeline_card.dart @@ -10,6 +10,17 @@ import '../../theme/focus_flow_tokens.dart'; import 'difficulty_bars.dart'; import 'reward_icon.dart'; +part 'task_card/card_colors.dart'; +part 'task_card/card_metrics.dart'; +part 'task_card/card_text.dart'; +part 'task_card/compact_card_text.dart'; +part 'task_card/expanded_card_content.dart'; +part 'task_card/no_op_icon.dart'; +part 'task_card/quick_actions.dart'; +part 'task_card/status_ring.dart'; +part 'task_card/task_timeline_card_state.dart'; +part 'task_card/trailing_controls.dart'; + /// Interactive card that renders a scheduled task on the compact timeline. class TaskTimelineCard extends StatefulWidget { /// Creates a task timeline card. @@ -32,613 +43,3 @@ class TaskTimelineCard extends StatefulWidget { @override State createState() => _TaskTimelineCardState(); } - -class _TaskTimelineCardState extends State { - bool _isHovered = false; - - @override - Widget build(BuildContext context) { - final card = widget.card; - final colors = _CardColors.forKind(card.visualKind); - return LayoutBuilder( - builder: (context, constraints) { - final metrics = _CardMetrics.fromConstraints( - constraints, - kind: card.visualKind, - ); - final content = SizedBox.expand( - child: Stack( - children: [ - Positioned.fill( - child: metrics.isCompact - ? _CompactCardText( - card: card, - color: colors.accent, - metrics: metrics, - onStatusPressed: widget.onStatusPressed, - ) - : _ExpandedCardContent( - card: card, - colors: colors, - metrics: metrics, - onStatusPressed: widget.onStatusPressed, - ), - ), - Positioned( - top: metrics.indicatorTop, - right: 0, - child: _TrailingControls( - card: card, - colors: colors, - metrics: metrics, - actionsVisible: card.showsQuickActions && _isHovered, - ), - ), - ], - ), - ); - return MouseRegion( - onEnter: (_) { - setState(() { - _isHovered = true; - }); - }, - onExit: (_) { - setState(() { - _isHovered = false; - }); - }, - child: Material( - color: Colors.transparent, - child: InkWell( - key: ValueKey(card.id), - borderRadius: BorderRadius.circular(metrics.cardRadius), - onTap: widget.onTap, - child: Container( - padding: metrics.padding, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: colors.background, - borderRadius: BorderRadius.circular(metrics.cardRadius), - border: Border.all( - color: colors.accent, - width: metrics.borderWidth, - ), - boxShadow: [ - BoxShadow( - color: colors.accent.withValues(alpha: 0.12), - blurRadius: metrics.shadowBlur, - spreadRadius: metrics.shadowSpread, - ), - ], - ), - child: content, - ), - ), - ), - ); - }, - ); - } -} - -class _CardMetrics { - const _CardMetrics({required this.scale, required this.height}); - - static const _referenceWidth = 640.0; - static const _referenceHeight = 88.0; - static const _freeSlotReferenceHeight = 108.0; - static const _compactHeightThreshold = 56.0; - static const _minimumScale = 0.16; - - factory _CardMetrics.fromConstraints( - BoxConstraints constraints, { - required TaskVisualKind kind, - }) { - final width = constraints.hasBoundedWidth - ? constraints.maxWidth - : _referenceWidth; - final height = constraints.hasBoundedHeight - ? constraints.maxHeight - : _referenceHeight; - final widthScale = width / _referenceWidth; - final heightScale = - height / - (kind == TaskVisualKind.freeSlot - ? _freeSlotReferenceHeight - : _referenceHeight); - final scale = math.min(widthScale, heightScale).clamp(_minimumScale, 1.0); - return _CardMetrics(scale: scale.toDouble(), height: height); - } - - final double scale; - final double height; - - bool get isCompact => height < _compactHeightThreshold; - - double get cardRadius => - math.min(FocusFlowTokens.cardRadius * scale, height / 2); - double get borderWidth => math.max(0.75, 1.2 * scale); - double get shadowBlur => isCompact ? 0 : 18 * scale; - double get shadowSpread => isCompact ? 0 : scale; - double get statusRingSize { - final availableHeight = math.max(8, height - padding.vertical); - return math.min(24, availableHeight * 0.78).toDouble(); - } - - double get statusBorderWidth => math.max(1.4, statusRingSize * 0.08); - double get statusIconSize => statusRingSize * 0.58; - double get statusGap => isCompact ? 7 : 22 * scale; - double get actionButtonSize => (30 * scale).clamp(14.0, 22.0).toDouble(); - double get actionIconSize => (16 * scale).clamp(9.0, 13.0).toDouble(); - double get actionGap => math.max(3, 5 * scale); - double get actionsWidth => actionButtonSize * 4 + actionGap; - double get indicatorTop => isCompact ? 0 : math.max(1, 2 * scale); - double get indicatorGap => math.max(3, 5 * scale); - double get rewardIconSize => 14; - double get titleSize => 10; - double get metaSize => 7; - double get titleMetaGap => 4 * scale; - double get freeSlotTimeGap => 10 * scale; - double get compactTextGap => math.max(3, 8 * scale); - double get indicatorWidth => - rewardIconSize + indicatorGap + difficultySize.width; - double get expandedTrailingReserve => - indicatorWidth + math.max(8, 12 * scale); - double get compactTrailingReserve => indicatorWidth + math.max(6, 8 * scale); - Size get difficultySize => Size(26, 14); - EdgeInsets get padding { - if (isCompact) { - return EdgeInsets.symmetric( - horizontal: math.max(4, 8 * scale), - vertical: math.max(1, 3 * scale), - ); - } - return EdgeInsets.fromLTRB(18 * scale, 10 * scale, 14 * scale, 10 * scale); - } -} - -class _ExpandedCardContent extends StatelessWidget { - const _ExpandedCardContent({ - required this.card, - required this.colors, - required this.metrics, - required this.onStatusPressed, - }); - - final TimelineCardModel card; - final _CardColors colors; - final _CardMetrics metrics; - final VoidCallback? onStatusPressed; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - if (card.visualKind != TaskVisualKind.freeSlot) ...[ - _StatusRing( - card: card, - color: colors.accent, - metrics: metrics, - onPressed: card.canToggleCompletion ? onStatusPressed : null, - ), - SizedBox(width: metrics.statusGap), - ], - Expanded( - child: Padding( - padding: EdgeInsets.only(right: metrics.expandedTrailingReserve), - child: _CardText( - card: card, - color: colors.accent, - metrics: metrics, - ), - ), - ), - ], - ); - } -} - -class _StatusRing extends StatelessWidget { - const _StatusRing({ - required this.card, - required this.color, - required this.metrics, - this.onPressed, - }); - - final TimelineCardModel card; - final Color color; - final _CardMetrics metrics; - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - final border = Border.all(color: color, width: metrics.statusBorderWidth); - final ring = Container( - key: ValueKey('${card.id}-status'), - width: metrics.statusRingSize, - height: metrics.statusRingSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: card.isCompleted ? color : Colors.transparent, - border: border, - ), - child: card.isCompleted - ? Icon( - Icons.check, - color: FocusFlowTokens.appBackground, - size: metrics.statusIconSize, - ) - : null, - ); - if (onPressed == null) { - return ring; - } - return Tooltip( - message: card.isCompleted ? 'Mark not done' : 'Mark complete', - child: Semantics( - button: true, - label: card.isCompleted - ? 'Mark ${card.title} not done' - : 'Mark ${card.title} complete', - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onPressed, - child: ring, - ), - ), - ), - ); - } -} - -class _CardText extends StatelessWidget { - const _CardText({ - required this.card, - required this.color, - required this.metrics, - }); - - final TimelineCardModel card; - final Color color; - final _CardMetrics metrics; - - @override - Widget build(BuildContext context) { - final titleStyle = TextStyle( - color: card.isCompleted - ? FocusFlowTokens.completedText - : FocusFlowTokens.textPrimary, - fontSize: metrics.titleSize, - fontWeight: FontWeight.w800, - decoration: card.isCompleted ? TextDecoration.lineThrough : null, - decorationColor: FocusFlowTokens.completedText, - decorationThickness: 2 * metrics.scale, - ); - return LayoutBuilder( - builder: (context, constraints) { - final textBlock = Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - card.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: titleStyle, - ), - SizedBox(height: metrics.titleMetaGap), - Text( - card.subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: card.isCompleted ? FocusFlowTokens.completedText : color, - fontSize: metrics.metaSize, - fontStyle: card.visualKind == TaskVisualKind.freeSlot - ? FontStyle.italic - : FontStyle.normal, - fontWeight: card.visualKind == TaskVisualKind.flexible - ? FontWeight.w700 - : FontWeight.w500, - ), - ), - if (card.visualKind == TaskVisualKind.freeSlot) ...[ - SizedBox(height: metrics.freeSlotTimeGap), - Text( - card.timeText, - style: TextStyle( - color: color, - fontSize: metrics.metaSize, - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ); - if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { - return textBlock; - } - return FittedBox( - alignment: Alignment.centerLeft, - fit: BoxFit.scaleDown, - child: SizedBox(width: constraints.maxWidth, child: textBlock), - ); - }, - ); - } -} - -class _CompactCardText extends StatelessWidget { - const _CompactCardText({ - required this.card, - required this.color, - required this.metrics, - required this.onStatusPressed, - }); - - final TimelineCardModel card; - final Color color; - final _CardMetrics metrics; - final VoidCallback? onStatusPressed; - - @override - Widget build(BuildContext context) { - final metaText = _compactMetaText(card); - final titleStyle = TextStyle( - color: card.isCompleted - ? FocusFlowTokens.completedText - : FocusFlowTokens.textPrimary, - fontSize: metrics.titleSize, - height: 1, - fontWeight: FontWeight.w800, - decoration: card.isCompleted ? TextDecoration.lineThrough : null, - decorationColor: FocusFlowTokens.completedText, - decorationThickness: math.max(1, 2 * metrics.scale), - ); - final metaStyle = TextStyle( - color: card.isCompleted ? FocusFlowTokens.completedText : color, - fontSize: metrics.metaSize, - height: 1, - fontWeight: FontWeight.w700, - ); - return LayoutBuilder( - builder: (context, constraints) { - final line = Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (card.visualKind != TaskVisualKind.freeSlot) ...[ - _StatusRing( - card: card, - color: color, - metrics: metrics, - onPressed: card.canToggleCompletion ? onStatusPressed : null, - ), - SizedBox(width: metrics.statusGap), - ], - Flexible( - flex: 2, - fit: FlexFit.loose, - child: Text( - card.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: titleStyle, - ), - ), - SizedBox(width: metrics.compactTextGap), - Flexible( - flex: 1, - fit: FlexFit.loose, - child: Text( - metaText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: metaStyle, - ), - ), - SizedBox(width: metrics.compactTrailingReserve), - ], - ); - if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { - return line; - } - return FittedBox( - alignment: Alignment.centerLeft, - fit: BoxFit.scaleDown, - child: SizedBox(width: constraints.maxWidth, child: line), - ); - }, - ); - } - - String _compactMetaText(TimelineCardModel card) { - if (card.visualKind == TaskVisualKind.freeSlot && - card.timeText.isNotEmpty) { - return card.timeText; - } - if (card.subtitle.isNotEmpty) { - return card.subtitle; - } - return card.timeText; - } -} - -class _TrailingControls extends StatelessWidget { - const _TrailingControls({ - required this.card, - required this.colors, - required this.metrics, - required this.actionsVisible, - }); - - final TimelineCardModel card; - final _CardColors colors; - final _CardMetrics metrics; - final bool actionsVisible; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (card.showsQuickActions) - _QuickActions( - color: colors.accent, - backgroundColor: colors.background.withValues(alpha: 1), - metrics: metrics, - visible: actionsVisible, - ), - RewardIcon(color: colors.reward, size: metrics.rewardIconSize), - SizedBox(width: metrics.indicatorGap), - DifficultyBars( - difficulty: card.difficultyIconToken, - color: colors.reward, - size: metrics.difficultySize, - ), - ], - ); - } -} - -class _QuickActions extends StatelessWidget { - const _QuickActions({ - required this.color, - required this.backgroundColor, - required this.metrics, - required this.visible, - }); - - final Color color; - final Color backgroundColor; - final _CardMetrics metrics; - final bool visible; - - @override - Widget build(BuildContext context) { - return ExcludeSemantics( - excluding: !visible, - child: IgnorePointer( - ignoring: !visible, - child: ClipRect( - child: AnimatedContainer( - width: visible ? metrics.actionsWidth : 0, - height: metrics.actionButtonSize, - decoration: BoxDecoration(color: backgroundColor), - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, - child: OverflowBox( - minWidth: metrics.actionsWidth, - maxWidth: metrics.actionsWidth, - alignment: Alignment.centerRight, - child: AnimatedOpacity( - opacity: visible ? 1 : 0, - duration: const Duration(milliseconds: 90), - curve: Curves.easeOut, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _NoOpIcon( - icon: Icons.check, - color: color, - metrics: metrics, - ), - _NoOpIcon( - icon: Icons.arrow_forward, - color: color, - metrics: metrics, - ), - _NoOpIcon( - icon: Icons.calendar_today, - color: color, - metrics: metrics, - ), - _NoOpIcon( - icon: Icons.wb_sunny_outlined, - color: color, - metrics: metrics, - ), - SizedBox(width: metrics.actionGap), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -class _NoOpIcon extends StatelessWidget { - const _NoOpIcon({ - required this.icon, - required this.color, - required this.metrics, - }); - - final IconData icon; - final Color color; - final _CardMetrics metrics; - - @override - Widget build(BuildContext context) { - return Tooltip( - message: 'No-op action', - child: GestureDetector( - onTap: () {}, - behavior: HitTestBehavior.opaque, - child: SizedBox.square( - dimension: metrics.actionButtonSize, - child: Icon(icon, color: color, size: metrics.actionIconSize), - ), - ), - ); - } -} - -class _CardColors { - const _CardColors({ - required this.accent, - required this.background, - required this.reward, - }); - - final Color accent; - final Color background; - final Color reward; - - factory _CardColors.forKind(TaskVisualKind kind) { - return switch (kind) { - TaskVisualKind.flexible => const _CardColors( - accent: FocusFlowTokens.flexibleGreen, - background: Color(0xCC021A0C), - reward: FocusFlowTokens.flexibleGreen, - ), - TaskVisualKind.required => const _CardColors( - accent: FocusFlowTokens.requiredPink, - background: Color(0xCC230814), - reward: FocusFlowTokens.requiredPink, - ), - TaskVisualKind.appointment => const _CardColors( - accent: FocusFlowTokens.appointmentBlue, - background: Color(0xCC031429), - reward: FocusFlowTokens.appointmentBlue, - ), - TaskVisualKind.freeSlot => const _CardColors( - accent: FocusFlowTokens.restPurple, - background: Color(0xCC210A33), - reward: FocusFlowTokens.restPurple, - ), - TaskVisualKind.completedSurprise => const _CardColors( - accent: FocusFlowTokens.completedGray, - background: Color(0xAA1C1D25), - reward: FocusFlowTokens.restPurple, - ), - }; - } -} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart index a00aa8a..8d795f9 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart @@ -6,6 +6,9 @@ import 'package:flutter/material.dart'; import '../../theme/focus_flow_tokens.dart'; import 'timeline_geometry.dart'; +part 'axis/timeline_grid.dart'; +part 'axis/timeline_grid_painter.dart'; + /// Time labels and rail shown beside the compact timeline. class TimelineAxis extends StatelessWidget { /// Creates a timeline axis for [geometry]. @@ -68,52 +71,3 @@ class TimelineAxis extends StatelessWidget { ); } } - -/// Background grid aligned to timeline tick marks. -class TimelineGrid extends StatelessWidget { - /// Creates a timeline grid for [geometry]. - const TimelineGrid({required this.geometry, this.topInset = 12, super.key}); - - /// Geometry used to position grid lines. - final TimelineGeometry geometry; - - /// Vertical inset before the first timeline tick. - final double topInset; - - @override - Widget build(BuildContext context) { - return CustomPaint( - painter: _TimelineGridPainter(geometry: geometry, topInset: topInset), - size: Size.infinite, - ); - } -} - -class _TimelineGridPainter extends CustomPainter { - const _TimelineGridPainter({required this.geometry, required this.topInset}); - - final TimelineGeometry geometry; - final double topInset; - - @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 + topInset), - Offset(size.width, tick.y + topInset), - paint - ..color = tick.major - ? FocusFlowTokens.gridLine.withValues(alpha: 0.72) - : FocusFlowTokens.gridLine, - ); - } - } - - @override - bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) { - return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset; - } -} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart index fb1d50e..e6a0a7a 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -10,6 +10,10 @@ import 'task_timeline_card.dart'; import 'timeline_axis.dart'; import 'timeline_geometry.dart'; +part 'timeline_view/timeline_card_placement.dart'; +part 'timeline_view/timeline_scroll_behavior.dart'; +part 'timeline_view/timeline_view_state.dart'; + /// Scrollable compact timeline view for Today cards. class TimelineView extends StatefulWidget { /// Creates a timeline view. @@ -48,374 +52,3 @@ class TimelineView extends StatefulWidget { @override State createState() => _TimelineViewState(); } - -class _TimelineViewState extends State { - static const _topInset = 12.0; - - final _scrollController = ScrollController(); - bool _didSetInitialScroll = false; - var _handledScrollRequest = 0; - late TimelineGeometry _geometry; - late double _contentHeight; - late List<_TimelineCardPlacement> _placements; - late int _cardsLayoutHash; - - @override - void initState() { - super.initState(); - _updateLayoutCache(); - } - - @override - void didUpdateWidget(covariant TimelineView oldWidget) { - super.didUpdateWidget(oldWidget); - final rangeChanged = - oldWidget.range.startMinutes != widget.range.startMinutes || - oldWidget.range.endMinutes != widget.range.endMinutes; - if (rangeChanged) { - _didSetInitialScroll = false; - } - final layoutChanged = _layoutHashFor(widget.cards) != _cardsLayoutHash; - if (rangeChanged || layoutChanged) { - _updateLayoutCache(); - } else if (!identical(oldWidget.cards, widget.cards)) { - _refreshPlacementCards(); - } - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - _scheduleInitialScroll(_geometry, constraints.maxHeight); - _scheduleTargetScroll(_geometry); - return ScrollConfiguration( - behavior: const _TimelineScrollBehavior(), - child: SingleChildScrollView( - controller: _scrollController, - child: SizedBox( - height: _contentHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RepaintBoundary( - child: TimelineAxis( - geometry: _geometry, - topInset: _topInset, - ), - ), - const SizedBox(width: 18), - Expanded( - child: LayoutBuilder( - builder: (context, cardConstraints) { - final trackWidth = cardConstraints.maxWidth; - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: RepaintBoundary( - child: TimelineGrid( - geometry: _geometry, - topInset: _topInset, - ), - ), - ), - for (final placement in _placements) - Positioned( - top: placement.top + _topInset, - left: placement.left(trackWidth), - width: placement.width(trackWidth), - height: placement.height, - child: RepaintBoundary( - child: TaskTimelineCard( - card: placement.card, - onTap: () => - widget.onCardSelected(placement.card), - onStatusPressed: - widget.onCardCompleted == null - ? null - : () => widget.onCardCompleted!( - placement.card, - ), - ), - ), - ), - ], - ); - }, - ), - ), - ], - ), - ), - ), - ); - }, - ); - } - - void _updateLayoutCache() { - _geometry = TimelineGeometry( - startMinutes: widget.range.startMinutes, - endMinutes: widget.range.endMinutes, - pixelsPerMinute: 2.45, - ); - _contentHeight = _geometry.totalHeight + _topInset + 24; - _placements = _TimelineCardPlacement.pack( - widget.cards, - geometry: _geometry, - ); - _cardsLayoutHash = _layoutHashFor(widget.cards); - } - - int _layoutHashFor(List cards) { - return Object.hashAll( - cards.map( - (card) => Object.hash(card.id, card.startMinutes, card.endMinutes), - ), - ); - } - - void _refreshPlacementCards() { - final cardsById = {for (final card in widget.cards) card.id: card}; - _placements = [ - for (final placement in _placements) - placement.withCard(cardsById[placement.card.id] ?? placement.card), - ]; - } - - void _scheduleInitialScroll( - TimelineGeometry geometry, - double viewportHeight, - ) { - if (_didSetInitialScroll || !viewportHeight.isFinite) { - return; - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_scrollController.hasClients) { - return; - } - final now = widget.now?.call() ?? DateTime.now(); - final currentMinute = now.hour * 60 + now.minute; - final clampedMinute = currentMinute - .clamp(geometry.startMinutes, geometry.endMinutes) - .toInt(); - final centeredOffset = - geometry.yForMinutesSinceMidnight(clampedMinute) + - _topInset - - viewportHeight / 2; - final maxOffset = _scrollController.position.maxScrollExtent; - final offset = centeredOffset.clamp(0, maxOffset).toDouble(); - _scrollController.jumpTo(offset); - _didSetInitialScroll = true; - }); - } - - void _scheduleTargetScroll(TimelineGeometry geometry) { - final targetCardId = widget.scrollTargetCardId; - if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) { - return; - } - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_scrollController.hasClients) { - return; - } - TimelineCardModel? targetCard; - for (final card in widget.cards) { - if (card.id == targetCardId) { - targetCard = card; - break; - } - } - if (targetCard == null) { - _handledScrollRequest = widget.scrollRequest; - return; - } - final targetOffset = - geometry.yForMinutesSinceMidnight(targetCard.startMinutes) + - _topInset - - 24; - final maxOffset = _scrollController.position.maxScrollExtent; - final offset = targetOffset.clamp(0, maxOffset).toDouble(); - _scrollController.jumpTo(offset); - _handledScrollRequest = widget.scrollRequest; - }); - } -} - -class _TimelineScrollBehavior extends MaterialScrollBehavior { - const _TimelineScrollBehavior(); - - @override - Set get dragDevices { - return {...super.dragDevices, PointerDeviceKind.mouse}; - } -} - -class _TimelineCardPlacement { - const _TimelineCardPlacement({ - required this.card, - required this.lane, - required this.laneCount, - required this.top, - required this.height, - }); - - static const _laneGap = 8.0; - - final TimelineCardModel card; - final int lane; - final int laneCount; - final double top; - final double height; - - _TimelineCardPlacement withCard(TimelineCardModel nextCard) { - if (identical(card, nextCard)) { - return this; - } - return _TimelineCardPlacement( - card: nextCard, - lane: lane, - laneCount: laneCount, - top: top, - height: height, - ); - } - - static List<_TimelineCardPlacement> pack( - List cards, { - required TimelineGeometry geometry, - }) { - if (cards.isEmpty) { - return const []; - } - final sorted = [...cards] - ..sort((a, b) { - final startComparison = a.startMinutes.compareTo(b.startMinutes); - if (startComparison != 0) { - return startComparison; - } - final endComparison = a.endMinutes.compareTo(b.endMinutes); - if (endComparison != 0) { - return endComparison; - } - return a.id.compareTo(b.id); - }); - final placements = <_TimelineCardPlacement>[]; - var groupStart = 0; - var groupEnd = _visualEnd(sorted.first, geometry); - for (var index = 1; index < sorted.length; index += 1) { - final card = sorted[index]; - final cardStart = _visualStart(card, geometry); - final cardEnd = _visualEnd(card, geometry); - if (cardStart < groupEnd) { - if (cardEnd > groupEnd) { - groupEnd = cardEnd; - } - continue; - } - placements.addAll( - _packGroup(sorted.sublist(groupStart, index), geometry: geometry), - ); - groupStart = index; - groupEnd = cardEnd; - } - placements.addAll( - _packGroup(sorted.sublist(groupStart), geometry: geometry), - ); - return placements; - } - - static List<_TimelineCardPlacement> _packGroup( - List group, { - required TimelineGeometry geometry, - }) { - final laneEnds = []; - final laneByCard = {}; - for (final card in group) { - final cardStart = _visualStart(card, geometry); - final cardEnd = _visualEnd(card, geometry); - var assignedLane = -1; - for (var lane = 0; lane < laneEnds.length; lane += 1) { - if (laneEnds[lane] <= cardStart) { - assignedLane = lane; - break; - } - } - if (assignedLane == -1) { - assignedLane = laneEnds.length; - laneEnds.add(cardEnd); - } else { - laneEnds[assignedLane] = cardEnd; - } - laneByCard[card] = assignedLane; - } - final laneCount = laneEnds.length; - return [ - for (final card in group) - _TimelineCardPlacement( - card: card, - lane: laneByCard[card]!, - laneCount: laneCount, - top: _visualStart(card, geometry), - height: geometry.heightForDuration( - card.endMinutes - card.startMinutes, - ), - ), - ]; - } - - static double _visualStart( - TimelineCardModel card, - TimelineGeometry geometry, - ) { - return geometry.yForMinutesSinceMidnight(card.startMinutes); - } - - static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) { - final duration = card.endMinutes - card.startMinutes; - return _visualStart(card, geometry) + geometry.heightForDuration(duration); - } - - double left(double trackWidth) { - final laneWidth = _laneWidth(trackWidth); - final laneGap = _laneGapForWidth(trackWidth); - return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap); - } - - double width(double trackWidth) { - return _laneWidth(trackWidth); - } - - double _availableWidth(double trackWidth) { - final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding; - if (availableWidth < 0) { - return 0; - } - return availableWidth; - } - - double _laneWidth(double trackWidth) { - final availableWidth = _availableWidth(trackWidth); - final laneGap = _laneGapForWidth(trackWidth); - return (availableWidth - laneGap * (laneCount - 1)) / laneCount; - } - - double _laneGapForWidth(double trackWidth) { - if (laneCount == 1) { - return 0; - } - final availableWidth = _availableWidth(trackWidth); - final totalGap = _laneGap * (laneCount - 1); - if (availableWidth <= totalGap) { - return 0; - } - return _laneGap; - } -} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_card_placement.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_card_placement.dart new file mode 100644 index 0000000..aecc8cd --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_card_placement.dart @@ -0,0 +1,163 @@ +part of '../timeline_view.dart'; + +class _TimelineCardPlacement { + const _TimelineCardPlacement({ + required this.card, + required this.lane, + required this.laneCount, + required this.top, + required this.height, + }); + + static const _laneGap = 8.0; + + final TimelineCardModel card; + final int lane; + final int laneCount; + final double top; + final double height; + + _TimelineCardPlacement withCard(TimelineCardModel nextCard) { + if (identical(card, nextCard)) { + return this; + } + return _TimelineCardPlacement( + card: nextCard, + lane: lane, + laneCount: laneCount, + top: top, + height: height, + ); + } + + static List<_TimelineCardPlacement> pack( + List cards, { + required TimelineGeometry geometry, + }) { + if (cards.isEmpty) { + return const []; + } + final sorted = [...cards] + ..sort((a, b) { + final startComparison = a.startMinutes.compareTo(b.startMinutes); + if (startComparison != 0) { + return startComparison; + } + final endComparison = a.endMinutes.compareTo(b.endMinutes); + if (endComparison != 0) { + return endComparison; + } + return a.id.compareTo(b.id); + }); + final placements = <_TimelineCardPlacement>[]; + var groupStart = 0; + var groupEnd = _visualEnd(sorted.first, geometry); + for (var index = 1; index < sorted.length; index += 1) { + final card = sorted[index]; + final cardStart = _visualStart(card, geometry); + final cardEnd = _visualEnd(card, geometry); + if (cardStart < groupEnd) { + if (cardEnd > groupEnd) { + groupEnd = cardEnd; + } + continue; + } + placements.addAll( + _packGroup(sorted.sublist(groupStart, index), geometry: geometry), + ); + groupStart = index; + groupEnd = cardEnd; + } + placements.addAll( + _packGroup(sorted.sublist(groupStart), geometry: geometry), + ); + return placements; + } + + static List<_TimelineCardPlacement> _packGroup( + List group, { + required TimelineGeometry geometry, + }) { + final laneEnds = []; + final laneByCard = {}; + for (final card in group) { + final cardStart = _visualStart(card, geometry); + final cardEnd = _visualEnd(card, geometry); + var assignedLane = -1; + for (var lane = 0; lane < laneEnds.length; lane += 1) { + if (laneEnds[lane] <= cardStart) { + assignedLane = lane; + break; + } + } + if (assignedLane == -1) { + assignedLane = laneEnds.length; + laneEnds.add(cardEnd); + } else { + laneEnds[assignedLane] = cardEnd; + } + laneByCard[card] = assignedLane; + } + final laneCount = laneEnds.length; + return [ + for (final card in group) + _TimelineCardPlacement( + card: card, + lane: laneByCard[card]!, + laneCount: laneCount, + top: _visualStart(card, geometry), + height: geometry.heightForDuration( + card.endMinutes - card.startMinutes, + ), + ), + ]; + } + + static double _visualStart( + TimelineCardModel card, + TimelineGeometry geometry, + ) { + return geometry.yForMinutesSinceMidnight(card.startMinutes); + } + + static double _visualEnd(TimelineCardModel card, TimelineGeometry geometry) { + final duration = card.endMinutes - card.startMinutes; + return _visualStart(card, geometry) + geometry.heightForDuration(duration); + } + + double left(double trackWidth) { + final laneWidth = _laneWidth(trackWidth); + final laneGap = _laneGapForWidth(trackWidth); + return FocusFlowTokens.cardHorizontalPadding + lane * (laneWidth + laneGap); + } + + double width(double trackWidth) { + return _laneWidth(trackWidth); + } + + double _availableWidth(double trackWidth) { + final availableWidth = trackWidth - FocusFlowTokens.cardHorizontalPadding; + if (availableWidth < 0) { + return 0; + } + return availableWidth; + } + + double _laneWidth(double trackWidth) { + final availableWidth = _availableWidth(trackWidth); + final laneGap = _laneGapForWidth(trackWidth); + return (availableWidth - laneGap * (laneCount - 1)) / laneCount; + } + + double _laneGapForWidth(double trackWidth) { + if (laneCount == 1) { + return 0; + } + final availableWidth = _availableWidth(trackWidth); + final totalGap = _laneGap * (laneCount - 1); + if (availableWidth <= totalGap) { + return 0; + } + return _laneGap; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_scroll_behavior.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_scroll_behavior.dart new file mode 100644 index 0000000..46472e1 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_scroll_behavior.dart @@ -0,0 +1,10 @@ +part of '../timeline_view.dart'; + +class _TimelineScrollBehavior extends MaterialScrollBehavior { + const _TimelineScrollBehavior(); + + @override + Set get dragDevices { + return {...super.dragDevices, PointerDeviceKind.mouse}; + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart new file mode 100644 index 0000000..e2eca0c --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view/timeline_view_state.dart @@ -0,0 +1,201 @@ +part of '../timeline_view.dart'; + +class _TimelineViewState extends State { + static const _topInset = 12.0; + + final _scrollController = ScrollController(); + bool _didSetInitialScroll = false; + var _handledScrollRequest = 0; + late TimelineGeometry _geometry; + late double _contentHeight; + late List<_TimelineCardPlacement> _placements; + late int _cardsLayoutHash; + + @override + void initState() { + super.initState(); + _updateLayoutCache(); + } + + @override + void didUpdateWidget(covariant TimelineView oldWidget) { + super.didUpdateWidget(oldWidget); + final rangeChanged = + oldWidget.range.startMinutes != widget.range.startMinutes || + oldWidget.range.endMinutes != widget.range.endMinutes; + if (rangeChanged) { + _didSetInitialScroll = false; + } + final layoutChanged = _layoutHashFor(widget.cards) != _cardsLayoutHash; + if (rangeChanged || layoutChanged) { + _updateLayoutCache(); + } else if (!identical(oldWidget.cards, widget.cards)) { + _refreshPlacementCards(); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + _scheduleInitialScroll(_geometry, constraints.maxHeight); + _scheduleTargetScroll(_geometry); + return ScrollConfiguration( + behavior: const _TimelineScrollBehavior(), + child: SingleChildScrollView( + controller: _scrollController, + child: SizedBox( + height: _contentHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RepaintBoundary( + child: TimelineAxis( + geometry: _geometry, + topInset: _topInset, + ), + ), + const SizedBox(width: 18), + Expanded( + child: LayoutBuilder( + builder: (context, cardConstraints) { + final trackWidth = cardConstraints.maxWidth; + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: RepaintBoundary( + child: TimelineGrid( + geometry: _geometry, + topInset: _topInset, + ), + ), + ), + for (final placement in _placements) + Positioned( + top: placement.top + _topInset, + left: placement.left(trackWidth), + width: placement.width(trackWidth), + height: placement.height, + child: RepaintBoundary( + child: TaskTimelineCard( + card: placement.card, + onTap: () => + widget.onCardSelected(placement.card), + onStatusPressed: + widget.onCardCompleted == null + ? null + : () => widget.onCardCompleted!( + placement.card, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + void _updateLayoutCache() { + _geometry = TimelineGeometry( + startMinutes: widget.range.startMinutes, + endMinutes: widget.range.endMinutes, + pixelsPerMinute: 2.45, + ); + _contentHeight = _geometry.totalHeight + _topInset + 24; + _placements = _TimelineCardPlacement.pack( + widget.cards, + geometry: _geometry, + ); + _cardsLayoutHash = _layoutHashFor(widget.cards); + } + + int _layoutHashFor(List cards) { + return Object.hashAll( + cards.map( + (card) => Object.hash(card.id, card.startMinutes, card.endMinutes), + ), + ); + } + + void _refreshPlacementCards() { + final cardsById = {for (final card in widget.cards) card.id: card}; + _placements = [ + for (final placement in _placements) + placement.withCard(cardsById[placement.card.id] ?? placement.card), + ]; + } + + void _scheduleInitialScroll( + TimelineGeometry geometry, + double viewportHeight, + ) { + if (_didSetInitialScroll || !viewportHeight.isFinite) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) { + return; + } + final now = widget.now?.call() ?? DateTime.now(); + final currentMinute = now.hour * 60 + now.minute; + final clampedMinute = currentMinute + .clamp(geometry.startMinutes, geometry.endMinutes) + .toInt(); + final centeredOffset = + geometry.yForMinutesSinceMidnight(clampedMinute) + + _topInset - + viewportHeight / 2; + final maxOffset = _scrollController.position.maxScrollExtent; + final offset = centeredOffset.clamp(0, maxOffset).toDouble(); + _scrollController.jumpTo(offset); + _didSetInitialScroll = true; + }); + } + + void _scheduleTargetScroll(TimelineGeometry geometry) { + final targetCardId = widget.scrollTargetCardId; + if (targetCardId == null || widget.scrollRequest == _handledScrollRequest) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) { + return; + } + TimelineCardModel? targetCard; + for (final card in widget.cards) { + if (card.id == targetCardId) { + targetCard = card; + break; + } + } + if (targetCard == null) { + _handledScrollRequest = widget.scrollRequest; + return; + } + final targetOffset = + geometry.yForMinutesSinceMidnight(targetCard.startMinutes) + + _topInset - + 24; + final maxOffset = _scrollController.position.maxScrollExtent; + final offset = targetOffset.clamp(0, maxOffset).toDouble(); + _scrollController.jumpTo(offset); + _handledScrollRequest = widget.scrollRequest; + }); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index 2e6c7af..98c5059 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -5,6 +5,11 @@ import 'package:flutter/material.dart'; import '../theme/focus_flow_tokens.dart'; +part 'top_bar/top_bar_icon_button.dart'; +part 'top_bar/top_bar_metrics.dart'; +part 'top_bar/top_bar_mode_toggle.dart'; +part 'top_bar/top_bar_settings_button.dart'; + /// Header bar for the compact Today view. class TopBar extends StatelessWidget { /// Creates a top bar with a display-ready [dateLabel]. @@ -79,177 +84,3 @@ class TopBar extends StatelessWidget { ); } } - -class _TopBarMetrics { - const _TopBarMetrics(this.scale); - - static const referenceHeight = 48.0; - static const _referenceWidth = 900.0; - static const _minimumScale = 0.44; - - factory _TopBarMetrics.fromWidth(double width) { - final safeWidth = width.isFinite ? width : _referenceWidth; - final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0); - return _TopBarMetrics(scale.toDouble()); - } - - final double scale; - - double get titleWidth => 112 * scale; - double get titleSize => FocusFlowTokens.pageTitleSize * scale; - double get titleGap => 30 * scale; - double get controlHeight => referenceHeight * scale; - double get iconButtonSize => referenceHeight * scale; - double get iconSize => 24 * scale; - double get dateWidth => 144 * scale; - double get dateSize => 17 * scale; - double get modeWidth => 220 * scale; - double get modeHeight => referenceHeight * scale; - double get modeLabelSize => 17 * scale; - double get settingsGap => 28 * scale; - double get settingsWidth => 136 * scale; - double get settingsHeight => referenceHeight * scale; - double get settingsIconSize => 20 * scale; - double get settingsTextSize => 15 * scale; - double get settingsTextGap => 8 * scale; - double get borderRadius => FocusFlowTokens.smallButtonRadius * scale; - double get borderWidth => scale < 0.7 ? 0.8 : 1; - EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); -} - -class _SettingsButton extends StatelessWidget { - const _SettingsButton({required this.metrics}); - - final _TopBarMetrics metrics; - - @override - Widget build(BuildContext context) { - return OutlinedButton( - onPressed: () {}, - style: OutlinedButton.styleFrom( - foregroundColor: FocusFlowTokens.textPrimary, - side: BorderSide( - color: FocusFlowTokens.subtleBorder, - width: metrics.borderWidth, - ), - fixedSize: Size(metrics.settingsWidth, metrics.settingsHeight), - minimumSize: Size.zero, - padding: metrics.buttonPadding, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(metrics.borderRadius), - ), - ), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.wb_sunny_outlined, size: metrics.settingsIconSize), - SizedBox(width: metrics.settingsTextGap), - Text( - 'Settings', - style: TextStyle( - fontSize: metrics.settingsTextSize, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ); - } -} - -class _IconButton extends StatelessWidget { - const _IconButton({ - required this.icon, - required this.metrics, - required this.onPressed, - }); - - final IconData icon; - final _TopBarMetrics metrics; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return IconButton( - onPressed: onPressed, - constraints: BoxConstraints.tightFor( - width: metrics.iconButtonSize, - height: metrics.iconButtonSize, - ), - color: FocusFlowTokens.textPrimary, - iconSize: metrics.iconSize, - padding: EdgeInsets.zero, - icon: Icon(icon), - visualDensity: VisualDensity.compact, - ); - } -} - -class _ModeToggle extends StatelessWidget { - const _ModeToggle({required this.metrics}); - - final _TopBarMetrics metrics; - - @override - Widget build(BuildContext context) { - return Container( - height: metrics.modeHeight, - width: metrics.modeWidth, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(metrics.borderRadius), - border: Border.all( - color: FocusFlowTokens.subtleBorder, - width: metrics.borderWidth, - ), - ), - child: Row( - children: [ - Expanded( - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: FocusFlowTokens.glassPanelStrong, - borderRadius: BorderRadius.circular(metrics.borderRadius), - border: Border.all( - color: FocusFlowTokens.navBorder, - width: metrics.borderWidth, - ), - ), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - 'Compact', - style: TextStyle(fontSize: metrics.modeLabelSize), - ), - ), - ), - ), - Expanded( - child: TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - minimumSize: Size.zero, - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - 'Normal', - style: TextStyle( - color: FocusFlowTokens.textPrimary, - fontSize: metrics.modeLabelSize, - ), - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart new file mode 100644 index 0000000..f0bf92c --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_icon_button.dart @@ -0,0 +1,29 @@ +part of '../top_bar.dart'; + +class _IconButton extends StatelessWidget { + const _IconButton({ + required this.icon, + required this.metrics, + required this.onPressed, + }); + + final IconData icon; + final _TopBarMetrics metrics; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + constraints: BoxConstraints.tightFor( + width: metrics.iconButtonSize, + height: metrics.iconButtonSize, + ), + color: FocusFlowTokens.textPrimary, + iconSize: metrics.iconSize, + padding: EdgeInsets.zero, + icon: Icon(icon), + visualDensity: VisualDensity.compact, + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_metrics.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_metrics.dart new file mode 100644 index 0000000..37afaf9 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_metrics.dart @@ -0,0 +1,38 @@ +part of '../top_bar.dart'; + +class _TopBarMetrics { + const _TopBarMetrics(this.scale); + + static const referenceHeight = 48.0; + static const _referenceWidth = 900.0; + static const _minimumScale = 0.44; + + factory _TopBarMetrics.fromWidth(double width) { + final safeWidth = width.isFinite ? width : _referenceWidth; + final scale = (safeWidth / _referenceWidth).clamp(_minimumScale, 1.0); + return _TopBarMetrics(scale.toDouble()); + } + + final double scale; + + double get titleWidth => 112 * scale; + double get titleSize => FocusFlowTokens.pageTitleSize * scale; + double get titleGap => 30 * scale; + double get controlHeight => referenceHeight * scale; + double get iconButtonSize => referenceHeight * scale; + double get iconSize => 24 * scale; + double get dateWidth => 144 * scale; + double get dateSize => 17 * scale; + double get modeWidth => 220 * scale; + double get modeHeight => referenceHeight * scale; + double get modeLabelSize => 17 * scale; + double get settingsGap => 28 * scale; + double get settingsWidth => 136 * scale; + double get settingsHeight => referenceHeight * scale; + double get settingsIconSize => 20 * scale; + double get settingsTextSize => 15 * scale; + double get settingsTextGap => 8 * scale; + double get borderRadius => FocusFlowTokens.smallButtonRadius * scale; + double get borderWidth => scale < 0.7 ? 0.8 : 1; + EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); +} diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_mode_toggle.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_mode_toggle.dart new file mode 100644 index 0000000..a5def8d --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_mode_toggle.dart @@ -0,0 +1,66 @@ +part of '../top_bar.dart'; + +class _ModeToggle extends StatelessWidget { + const _ModeToggle({required this.metrics}); + + final _TopBarMetrics metrics; + + @override + Widget build(BuildContext context) { + return Container( + height: metrics.modeHeight, + width: metrics.modeWidth, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(metrics.borderRadius), + border: Border.all( + color: FocusFlowTokens.subtleBorder, + width: metrics.borderWidth, + ), + ), + child: Row( + children: [ + Expanded( + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: FocusFlowTokens.glassPanelStrong, + borderRadius: BorderRadius.circular(metrics.borderRadius), + border: Border.all( + color: FocusFlowTokens.navBorder, + width: metrics.borderWidth, + ), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'Compact', + style: TextStyle(fontSize: metrics.modeLabelSize), + ), + ), + ), + ), + Expanded( + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'Normal', + style: TextStyle( + color: FocusFlowTokens.textPrimary, + fontSize: metrics.modeLabelSize, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart new file mode 100644 index 0000000..2a3166a --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart @@ -0,0 +1,45 @@ +part of '../top_bar.dart'; + +class _SettingsButton extends StatelessWidget { + const _SettingsButton({required this.metrics}); + + final _TopBarMetrics metrics; + + @override + Widget build(BuildContext context) { + return OutlinedButton( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: FocusFlowTokens.textPrimary, + side: BorderSide( + color: FocusFlowTokens.subtleBorder, + width: metrics.borderWidth, + ), + fixedSize: Size(metrics.settingsWidth, metrics.settingsHeight), + minimumSize: Size.zero, + padding: metrics.buttonPadding, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(metrics.borderRadius), + ), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.wb_sunny_outlined, size: metrics.settingsIconSize), + SizedBox(width: metrics.settingsTextGap), + Text( + 'Settings', + style: TextStyle( + fontSize: metrics.settingsTextSize, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + } +}