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 8a2df03..a1d37f5 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -167,14 +167,14 @@ class DemoSchedulerComposition { } /// Creates an application operation context for the supplied [operationId]. - ApplicationOperationContext context(String operationId) { + ApplicationOperationContext context(String operationId, {DateTime? now}) { return ApplicationOperationContext.start( operationId: operationId, ownerTimeZone: OwnerTimeZoneContext( ownerId: ownerId, timeZoneId: timeZoneId, ), - clock: FixedClock(readAt), + clock: FixedClock(now ?? readAt), idGenerator: SequentialIdGenerator(prefix: operationId), ); } @@ -215,18 +215,18 @@ class DemoSchedulerComposition { createdAt: createdAt, ), _task( - id: 'cleaned-kitchen-counter-early', + id: 'cleaned-kitchen-counter', title: 'Cleaned kitchen counter', - type: TaskType.surprise, + type: TaskType.flexible, status: TaskStatus.completed, date: date, - startHour: 16, - startMinute: 45, - endHour: 17, - endMinute: 0, + startHour: 8, + startMinute: 15, + endHour: 8, + endMinute: 30, reward: RewardLevel.medium, difficulty: DifficultyLevel.medium, - completedAt: _instant(date, 16, 20), + completedAt: _instant(date, 8, 5), createdAt: createdAt, ), _task( @@ -313,21 +313,6 @@ class DemoSchedulerComposition { difficulty: DifficultyLevel.notSet, createdAt: createdAt, ), - _task( - id: 'cleaned-kitchen-counter-late', - title: 'Cleaned kitchen counter', - type: TaskType.surprise, - status: TaskStatus.completed, - date: date, - startHour: 20, - startMinute: 15, - endHour: 20, - endMinute: 30, - reward: RewardLevel.medium, - difficulty: DifficultyLevel.medium, - completedAt: _instant(date, 20, 20), - createdAt: createdAt, - ), ]; } 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 6837123..a56520d 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -3,6 +3,7 @@ library; import 'package:flutter/material.dart'; +import '../controllers/scheduler_command_controller.dart'; import '../controllers/today_screen_controller.dart'; import '../models/today_screen_models.dart'; import '../theme/focus_flow_theme.dart'; @@ -47,21 +48,40 @@ class FocusFlowHome extends StatefulWidget { } class _FocusFlowHomeState extends State { - late final TodayScreenController controller = widget.composition - .createTodayScreenController(); + 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( @@ -84,12 +104,14 @@ class _FocusFlowHomeState extends State { ), 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, ), }; @@ -118,12 +140,14 @@ class _TodayScreenScaffold extends StatefulWidget { 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 @@ -160,12 +184,16 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TopBar(dateLabel: widget.data.dateLabel), - const SizedBox(height: 24), - RequiredBanner( - model: widget.data.requiredBanner, - onShowUpcoming: _showUpcomingRequiredTask, - ), - const SizedBox(height: 22), + 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, @@ -173,6 +201,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { scrollTargetCardId: _timelineScrollTargetCardId, scrollRequest: _timelineScrollRequest, onCardSelected: widget.onSelectCard, + onCardCompleted: widget.onCompleteCard, ), ), ], @@ -191,6 +220,9 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { 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/scheduler_command_controller.dart b/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart index 638af12..b820393 100644 --- a/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart @@ -6,7 +6,7 @@ import 'package:scheduler_core/scheduler_core.dart'; /// Builds an application operation context for a UI command operation. typedef OperationContextFactory = - ApplicationOperationContext Function(String operationId); + ApplicationOperationContext Function(String operationId, {DateTime? now}); /// Refreshes any reads that should reflect a completed command. typedef ReadRefresh = Future Function(); @@ -70,7 +70,8 @@ class SchedulerCommandController extends ChangeNotifier { 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; @@ -83,6 +84,9 @@ class SchedulerCommandController extends ChangeNotifier { /// 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(); @@ -132,13 +136,70 @@ class SchedulerCommandController extends ChangeNotifier { 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) { - return commands.completeFlexibleTask( - context: contextFor(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, @@ -183,4 +244,6 @@ class SchedulerCommandController extends ChangeNotifier { _state = next; notifyListeners(); } + + static DateTime _utcNow() => DateTime.now().toUtc(); } diff --git a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart index 225d5cb..a7e2953 100644 --- a/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart +++ b/apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart @@ -76,6 +76,7 @@ class TodayScreenController extends ChangeNotifier { } final data = TodayScreenData.fromTodayState(result.requireValue); + _syncSelectedCard(data); _state = data.cards.isEmpty ? const TodayScreenEmpty() : TodayScreenReady(data); @@ -103,4 +104,18 @@ class TodayScreenController extends ChangeNotifier { _selectedCard = null; notifyListeners(); } + + void _syncSelectedCard(TodayScreenData data) { + final selected = _selectedCard; + if (selected == null) { + return; + } + for (final card in data.cards) { + if (card.id == selected.id) { + _selectedCard = card; + return; + } + } + _selectedCard = null; + } } 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 eb9f28e..fd18048 100644 --- a/apps/focus_flow_flutter/lib/models/today_screen_models.dart +++ b/apps/focus_flow_flutter/lib/models/today_screen_models.dart @@ -124,12 +124,14 @@ class TimelineCardModel { 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, }); @@ -146,18 +148,21 @@ class TimelineCardModel { ? '' : '$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(visualKind), + 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, @@ -179,6 +184,9 @@ class TimelineCardModel { /// 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; @@ -188,6 +196,9 @@ class TimelineCardModel { /// Optional task duration in minutes. final int? durationMinutes; + /// Source task scheduling behavior type. + final TaskType taskType; + /// Visual category used for styling. final TaskVisualKind visualKind; @@ -206,6 +217,20 @@ class TimelineCardModel { /// 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) { @@ -247,13 +272,17 @@ TaskVisualKind _visualKindFor(TodayTimelineItem item) { }; } -String _typeLabelFor(TaskVisualKind visualKind) { - return switch (visualKind) { - TaskVisualKind.flexible => 'Flexible', - TaskVisualKind.required => 'Required', - TaskVisualKind.appointment => 'Required', - TaskVisualKind.freeSlot => 'Free Slot', - TaskVisualKind.completedSurprise => 'Surprise task', +String _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', }; } @@ -265,9 +294,10 @@ String _subtitleFor( if (visualKind == TaskVisualKind.freeSlot) { return 'Intentional rest'; } - if (visualKind == TaskVisualKind.completedSurprise) { - final completedAt = _formatTime(item.item.end ?? item.end); - return 'Surprise task - Completed at $completedAt'; + if (item.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'; diff --git a/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart b/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart index d6ab29a..616f985 100644 --- a/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart +++ b/apps/focus_flow_flutter/lib/theme/focus_flow_tokens.dart @@ -93,7 +93,7 @@ abstract final class FocusFlowTokens { static const bannerRadius = 8.0; /// Border radius for timeline cards. - static const cardRadius = 8.0; + static const cardRadius = 12.0; /// Border radius for task selection modals. static const modalRadius = 8.0; @@ -111,7 +111,7 @@ abstract final class FocusFlowTokens { static const cardMetaSize = 16.0; /// Font size for sidebar navigation labels. - static const sidebarNavSize = 18.0; + static const sidebarNavSize = 16.0; /// Font size for modal titles. static const modalTitleSize = 26.0; diff --git a/apps/focus_flow_flutter/lib/widgets/required_banner.dart b/apps/focus_flow_flutter/lib/widgets/required_banner.dart index 0bbb5c7..99cd71d 100644 --- a/apps/focus_flow_flutter/lib/widgets/required_banner.dart +++ b/apps/focus_flow_flutter/lib/widgets/required_banner.dart @@ -21,7 +21,7 @@ class RequiredBanner extends StatelessWidget { Widget build(BuildContext context) { final banner = model; if (banner == null) { - return const SizedBox(height: _RequiredBannerMetrics.referenceHeight); + return const SizedBox.shrink(); } return LayoutBuilder( builder: (context, constraints) { @@ -119,11 +119,11 @@ class _RequiredBannerMetrics { double get iconSize => 24 * scale; double get iconGap => 26 * scale; double get buttonGap => 20 * scale; - double get textSize => 19 * scale; + double get textSize => (19 * scale).clamp(13.0, 14.0).toDouble(); double get buttonWidth => 176 * scale; double get buttonHeight => 46 * scale; double get buttonRadius => FocusFlowTokens.smallButtonRadius * scale; - double get buttonTextSize => 16 * scale; + double get buttonTextSize => (16 * scale).clamp(11.0, 12.5).toDouble(); double get buttonIconSize => 20 * scale; double get buttonTextGap => 6 * scale; EdgeInsets get buttonPadding => EdgeInsets.symmetric(horizontal: 12 * scale); 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 b7fd4de..df6c5fb 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -14,6 +14,7 @@ class TaskSelectionModal extends StatelessWidget { const TaskSelectionModal({ required this.card, required this.onClose, + this.onStatusPressed, super.key, }); @@ -23,6 +24,9 @@ class TaskSelectionModal extends StatelessWidget { /// Callback invoked when the modal should close. final VoidCallback onClose; + /// Callback invoked when the status circle should toggle completion. + final VoidCallback? onStatusPressed; + @override Widget build(BuildContext context) { final accent = _accentFor(card.visualKind); @@ -43,13 +47,10 @@ class TaskSelectionModal extends StatelessWidget { children: [ Row( children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: accent, width: 4), - ), + _ModalStatusButton( + card: card, + color: accent, + onPressed: onStatusPressed, ), const SizedBox(width: 28), Expanded( @@ -65,13 +66,7 @@ class TaskSelectionModal extends StatelessWidget { ), ), const SizedBox(height: 6), - Text( - '${card.typeLabel} - ${card.timeText}', - style: const TextStyle( - color: FocusFlowTokens.textMuted, - fontSize: 17, - ), - ), + _HeaderMetadata(card: card), ], ), ), @@ -137,6 +132,93 @@ 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}); 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 94beab4..46053e2 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 @@ -13,7 +13,12 @@ import 'reward_icon.dart'; /// Interactive card that renders a scheduled task on the compact timeline. class TaskTimelineCard extends StatefulWidget { /// Creates a task timeline card. - const TaskTimelineCard({required this.card, required this.onTap, super.key}); + const TaskTimelineCard({ + required this.card, + required this.onTap, + this.onStatusPressed, + super.key, + }); /// Card model to render. final TimelineCardModel card; @@ -21,6 +26,9 @@ class TaskTimelineCard extends StatefulWidget { /// Callback invoked when the card is tapped. final VoidCallback onTap; + /// Callback invoked when the left status control is clicked. + final VoidCallback? onStatusPressed; + @override State createState() => _TaskTimelineCardState(); } @@ -38,51 +46,37 @@ class _TaskTimelineCardState extends State { constraints, kind: card.visualKind, ); - final content = metrics.isCompact - ? _CompactCardText( - card: card, - color: colors.accent, - metrics: metrics, - ) - : Row( - children: [ - _StatusRing( - card: card, - color: colors.accent, - metrics: metrics, - ), - SizedBox(width: metrics.statusGap), - Expanded( - child: _CardText( - card: card, - color: colors.accent, - metrics: metrics, - ), - ), - if (card.showsQuickActions) - _QuickActions( - color: colors.accent, - metrics: metrics, - visible: _isHovered, - ), - _Badge( - metrics: metrics, - child: RewardIcon( - color: colors.reward, - size: metrics.rewardIconSize, - ), - ), - SizedBox(width: metrics.badgeGap), - _Badge( - metrics: metrics, - child: DifficultyBars( - difficulty: card.difficultyIconToken, - color: colors.reward, - size: metrics.difficultySize, - ), - ), - ], - ); + 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(() { @@ -167,29 +161,36 @@ class _CardMetrics { 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 => 34 * scale; - double get statusBorderWidth => math.max(1.2, 3 * scale); - double get freeSlotStatusBorderWidth => math.max(1, 2 * scale); - double get statusIconSize => 24 * scale; - double get statusGap => 22 * scale; - double get actionButtonSize => 40 * scale; - double get actionIconSize => 22 * scale; - double get actionGap => 10 * scale; - double get badgeWidth => 62 * scale; - double get badgeHeight => 48 * scale; - double get badgeRadius => 7 * scale; - double get badgeGap => 8 * scale; - double get rewardIconSize => 26 * scale; - double get titleSize => FocusFlowTokens.cardTitleSize * scale; - double get metaSize => FocusFlowTokens.cardMetaSize * 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(6, 16 * scale); - Size get difficultySize => Size(54 * scale, 34 * 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, 12 * scale), + horizontal: math.max(4, 8 * scale), vertical: math.max(1, 3 * scale), ); } @@ -197,36 +198,100 @@ class _CardMetrics { } } +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: card.visualKind == TaskVisualKind.freeSlot - ? metrics.freeSlotStatusBorderWidth - : metrics.statusBorderWidth, - style: card.visualKind == TaskVisualKind.freeSlot - ? BorderStyle.solid - : BorderStyle.solid, - ); - return Container( + 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, border: border), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: card.isCompleted ? color : Colors.transparent, + border: border, + ), child: card.isCompleted - ? Icon(Icons.check, color: color, size: metrics.statusIconSize) + ? 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, + ), + ), + ), + ); } } @@ -313,11 +378,13 @@ class _CompactCardText extends StatelessWidget { 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) { @@ -344,8 +411,18 @@ class _CompactCardText extends StatelessWidget { final line = Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Expanded( - flex: 3, + 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, @@ -355,7 +432,8 @@ class _CompactCardText extends StatelessWidget { ), SizedBox(width: metrics.compactTextGap), Flexible( - flex: 2, + flex: 1, + fit: FlexFit.loose, child: Text( metaText, maxLines: 1, @@ -363,6 +441,7 @@ class _CompactCardText extends StatelessWidget { style: metaStyle, ), ), + SizedBox(width: metrics.compactTrailingReserve), ], ); if (!constraints.hasBoundedWidth || !constraints.hasBoundedHeight) { @@ -389,14 +468,53 @@ class _CompactCardText extends StatelessWidget { } } +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; @@ -406,31 +524,49 @@ class _QuickActions extends StatelessWidget { excluding: !visible, child: IgnorePointer( ignoring: !visible, - child: AnimatedOpacity( - opacity: visible ? 1 : 0, - duration: const Duration(milliseconds: 120), - 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, + 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), + ], + ), ), - _NoOpIcon( - icon: Icons.calendar_today, - color: color, - metrics: metrics, - ), - _NoOpIcon( - icon: Icons.wb_sunny_outlined, - color: color, - metrics: metrics, - ), - SizedBox(width: metrics.actionGap), - ], + ), ), ), ), @@ -451,40 +587,16 @@ class _NoOpIcon extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( - onPressed: () {}, - constraints: BoxConstraints.tightFor( - width: metrics.actionButtonSize, - height: metrics.actionButtonSize, + 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), + ), ), - color: color, - iconSize: metrics.actionIconSize, - padding: EdgeInsets.zero, - icon: Icon(icon), - tooltip: 'No-op action', - visualDensity: VisualDensity.compact, - ); - } -} - -class _Badge extends StatelessWidget { - const _Badge({required this.child, required this.metrics}); - - final Widget child; - final _CardMetrics metrics; - - @override - Widget build(BuildContext context) { - return Container( - width: metrics.badgeWidth, - height: metrics.badgeHeight, - alignment: Alignment.center, - decoration: BoxDecoration( - color: FocusFlowTokens.panelBackground.withValues(alpha: 0.84), - borderRadius: BorderRadius.circular(metrics.badgeRadius), - border: Border.all(color: FocusFlowTokens.subtleBorder), - ), - child: child, ); } } 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 3858515..a00aa8a 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_axis.dart @@ -9,32 +9,38 @@ import 'timeline_geometry.dart'; /// Time labels and rail shown beside the compact timeline. class TimelineAxis extends StatelessWidget { /// Creates a timeline axis for [geometry]. - const TimelineAxis({required this.geometry, super.key}); + const TimelineAxis({required this.geometry, this.topInset = 12, super.key}); /// Geometry used to position labels and tick marks. final TimelineGeometry geometry; + /// Vertical inset before the first timeline tick. + final double topInset; + @override Widget build(BuildContext context) { final ticks = geometry.ticks(); return SizedBox( width: FocusFlowTokens.timelineRailWidth, - height: geometry.totalHeight + 24, + height: geometry.totalHeight + topInset + 24, child: Stack( children: [ Positioned( left: 92, - top: 0, + top: topInset, bottom: 0, child: Container(width: 2, color: FocusFlowTokens.rail), ), for (final tick in ticks) Positioned( - top: tick.y - 10, + top: tick.y + topInset - 10, left: 0, width: 86, child: Text( tick.label, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, textAlign: TextAlign.right, style: TextStyle( color: tick.major @@ -47,7 +53,7 @@ class TimelineAxis extends StatelessWidget { ), for (final tick in ticks.where((tick) => tick.major)) Positioned( - top: tick.y - 6, + top: tick.y + topInset - 6, left: 87, child: const DecoratedBox( decoration: BoxDecoration( @@ -66,24 +72,28 @@ 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, super.key}); + 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), + painter: _TimelineGridPainter(geometry: geometry, topInset: topInset), size: Size.infinite, ); } } class _TimelineGridPainter extends CustomPainter { - const _TimelineGridPainter(this.geometry); + const _TimelineGridPainter({required this.geometry, required this.topInset}); final TimelineGeometry geometry; + final double topInset; @override void paint(Canvas canvas, Size size) { @@ -92,8 +102,8 @@ class _TimelineGridPainter extends CustomPainter { ..strokeWidth = 1; for (final tick in geometry.ticks()) { canvas.drawLine( - Offset(0, tick.y), - Offset(size.width, tick.y), + Offset(0, tick.y + topInset), + Offset(size.width, tick.y + topInset), paint ..color = tick.major ? FocusFlowTokens.gridLine.withValues(alpha: 0.72) @@ -104,6 +114,6 @@ class _TimelineGridPainter extends CustomPainter { @override bool shouldRepaint(covariant _TimelineGridPainter oldDelegate) { - return oldDelegate.geometry != geometry; + return oldDelegate.geometry != geometry || oldDelegate.topInset != topInset; } } diff --git a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart index e6f806b..1556809 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_geometry.dart @@ -60,6 +60,26 @@ class TimelineGeometry { ]; } + @override + bool operator ==(Object other) { + return identical(this, other) || + other is TimelineGeometry && + other.startMinutes == startMinutes && + other.endMinutes == endMinutes && + other.pixelsPerMinute == pixelsPerMinute && + other.minCardHeight == minCardHeight; + } + + @override + int get hashCode { + return Object.hash( + startMinutes, + endMinutes, + pixelsPerMinute, + minCardHeight, + ); + } + static String _labelFor(int minutes) { final hour24 = (minutes ~/ 60) % 24; final minute = minutes % 60; 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 dcd98d5..fb1d50e 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -1,6 +1,7 @@ /// Renders Timeline View pieces for the FocusFlow Flutter timeline. library; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../models/today_screen_models.dart'; @@ -16,6 +17,7 @@ class TimelineView extends StatefulWidget { required this.cards, required this.range, required this.onCardSelected, + this.onCardCompleted, this.now, this.scrollTargetCardId, this.scrollRequest = 0, @@ -31,6 +33,9 @@ class TimelineView extends StatefulWidget { /// Callback invoked when a card is selected. final ValueChanged onCardSelected; + /// Callback invoked when a task card status control requests completion. + final ValueChanged? onCardCompleted; + /// Clock used to center the initial scroll position. final DateTime Function()? now; @@ -45,17 +50,37 @@ class TimelineView extends StatefulWidget { } 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); - if (oldWidget.range.startMinutes != widget.range.startMinutes || - oldWidget.range.endMinutes != widget.range.endMinutes) { + 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 @@ -66,62 +91,68 @@ class _TimelineViewState extends State { @override Widget build(BuildContext context) { - final geometry = TimelineGeometry( - startMinutes: widget.range.startMinutes, - endMinutes: widget.range.endMinutes, - pixelsPerMinute: 2.45, - ); - final contentHeight = geometry.totalHeight + 24; - final placements = _TimelineCardPlacement.pack( - widget.cards, - geometry: geometry, - ); return LayoutBuilder( builder: (context, constraints) { - _scheduleInitialScroll(geometry, constraints.maxHeight); - _scheduleTargetScroll(geometry); - return SingleChildScrollView( - controller: _scrollController, - child: SizedBox( - height: contentHeight, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TimelineAxis(geometry: geometry), - const SizedBox(width: 18), - Expanded( - child: LayoutBuilder( - builder: (context, cardConstraints) { - final trackWidth = cardConstraints.maxWidth; - return Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: TimelineGrid(geometry: geometry), - ), - for (final placement in placements) - Positioned( - top: geometry.yForMinutesSinceMidnight( - placement.card.startMinutes, - ), - left: placement.left(trackWidth), - width: placement.width(trackWidth), - height: geometry.heightForDuration( - placement.card.endMinutes - - placement.card.startMinutes, - ), - child: TaskTimelineCard( - card: placement.card, - onTap: () => - widget.onCardSelected(placement.card), + _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, + ), + ), + ), + ), + ], + ); + }, + ), ), - ), - ], + ], + ), ), ), ); @@ -129,6 +160,36 @@ class _TimelineViewState extends State { ); } + 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, @@ -146,7 +207,9 @@ class _TimelineViewState extends State { .clamp(geometry.startMinutes, geometry.endMinutes) .toInt(); final centeredOffset = - geometry.yForMinutesSinceMidnight(clampedMinute) - viewportHeight / 2; + geometry.yForMinutesSinceMidnight(clampedMinute) + + _topInset - + viewportHeight / 2; final maxOffset = _scrollController.position.maxScrollExtent; final offset = centeredOffset.clamp(0, maxOffset).toDouble(); _scrollController.jumpTo(offset); @@ -175,7 +238,9 @@ class _TimelineViewState extends State { return; } final targetOffset = - geometry.yForMinutesSinceMidnight(targetCard.startMinutes) - 24; + geometry.yForMinutesSinceMidnight(targetCard.startMinutes) + + _topInset - + 24; final maxOffset = _scrollController.position.maxScrollExtent; final offset = targetOffset.clamp(0, maxOffset).toDouble(); _scrollController.jumpTo(offset); @@ -184,11 +249,22 @@ class _TimelineViewState extends State { } } +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; @@ -196,6 +272,21 @@ class _TimelineCardPlacement { 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, { @@ -272,6 +363,10 @@ class _TimelineCardPlacement { card: card, lane: laneByCard[card]!, laneCount: laneCount, + top: _visualStart(card, geometry), + height: geometry.heightForDuration( + card.endMinutes - card.startMinutes, + ), ), ]; } diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index d641983..00c6893 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -12,7 +12,10 @@ import 'package:focus_flow_flutter/models/today_screen_models.dart'; import 'package:focus_flow_flutter/theme/focus_flow_theme.dart'; import 'package:focus_flow_flutter/widgets/required_banner.dart'; import 'package:focus_flow_flutter/widgets/timeline/difficulty_bars.dart'; +import 'package:focus_flow_flutter/widgets/timeline/reward_icon.dart'; import 'package:focus_flow_flutter/widgets/timeline/task_timeline_card.dart'; +import 'package:focus_flow_flutter/widgets/timeline/timeline_axis.dart'; +import 'package:focus_flow_flutter/widgets/timeline/timeline_geometry.dart'; import 'package:focus_flow_flutter/widgets/timeline/timeline_view.dart'; import 'package:focus_flow_flutter/widgets/top_bar.dart'; @@ -43,7 +46,9 @@ void main() { findsOneWidget, ); expect(find.text('Clean coffee maker'), findsOneWidget); - expect(find.text('Cleaned kitchen counter'), findsNWidgets(2)); + expect(find.text('Cleaned kitchen counter'), findsOneWidget); + expect(find.text('Completed at: 8:05 AM'), findsOneWidget); + expect(find.textContaining('Surprise task'), findsNothing); expect(find.text('Sort mail'), findsOneWidget); expect(find.text('Start laundry'), findsOneWidget); expect(find.text('Water plants'), findsOneWidget); @@ -67,7 +72,7 @@ void main() { expect(_kindFor(data, 'doctor-appointment'), TaskVisualKind.appointment); expect(_kindFor(data, 'free-slot'), TaskVisualKind.freeSlot); expect( - _kindFor(data, 'cleaned-kitchen-counter-early'), + _kindFor(data, 'cleaned-kitchen-counter'), TaskVisualKind.completedSurprise, ); }, @@ -180,7 +185,7 @@ void main() { await tester.tap(find.text('Show upcoming')); await tester.pumpAndSettle(); - expect(scrollable.position.pixels, closeTo(2622, 1)); + expect(scrollable.position.pixels, closeTo(2634, 1)); expect(find.byKey(const ValueKey('pay-bill')), findsOneWidget); }); @@ -205,10 +210,69 @@ void main() { final scrollable = tester.state(find.byType(Scrollable)); final position = scrollable.position; expect(position.maxScrollExtent, greaterThan(3000)); - expect(position.pixels, closeTo(1564, 1)); + expect(position.pixels, closeTo(1576, 1)); }, ); + testWidgets('timeline hour labels stay on one line', (tester) async { + await _pumpConstrainedWidget( + tester, + size: const Size(140, 220), + child: const TimelineAxis( + geometry: TimelineGeometry( + startMinutes: 0, + endMinutes: 60, + pixelsPerMinute: 2.45, + ), + ), + ); + + expect(tester.takeException(), isNull); + + final midnight = tester.widget(find.text('12:00 AM')); + final oneAm = tester.widget(find.text('1:00 AM')); + expect(midnight.maxLines, 1); + expect(oneAm.maxLines, 1); + expect(midnight.softWrap, isFalse); + expect(oneAm.softWrap, isFalse); + expect(midnight.overflow, TextOverflow.visible); + expect(oneAm.overflow, TextOverflow.visible); + expect( + tester.getSize(find.text('12:00 AM')).height, + closeTo(tester.getSize(find.text('1:00 AM')).height, 0.1), + ); + expect( + tester.getRect(find.text('12:00 AM')).top, + greaterThanOrEqualTo(tester.getRect(find.byType(TimelineAxis)).top), + ); + }); + + testWidgets('timeline scrolls with a mouse drag', (tester) async { + await _pumpConstrainedWidget( + tester, + size: const Size(680, 400), + child: TimelineView( + cards: const [], + range: TodayScreenData.defaultRange, + now: () => DateTime(2026, 6, 30, 12), + onCardSelected: (_) {}, + ), + ); + + expect(tester.takeException(), isNull); + + final scrollable = tester.state(find.byType(Scrollable)); + final beforeDrag = scrollable.position.pixels; + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -180), + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, greaterThan(beforeDrag + 80)); + }); + testWidgets('timeline splits partial overlaps and reuses empty lanes', ( tester, ) async { @@ -289,8 +353,8 @@ void main() { final cleanCoffee = tester.getRect( find.byKey(const ValueKey('clean-coffee-maker')), ); - final cleanedKitchenEarly = tester.getRect( - find.byKey(const ValueKey('cleaned-kitchen-counter-early')), + final cleanedKitchenCounter = tester.getRect( + find.byKey(const ValueKey('cleaned-kitchen-counter')), ); expect(startLaundry.left, greaterThan(sortMail.left)); @@ -299,10 +363,14 @@ void main() { expect(waterPlants.width, closeTo(sortMail.width, 1)); expect(sortMail.right, lessThan(startLaundry.left)); expect(waterPlants.right, lessThan(startLaundry.left)); - expect(cleanCoffee.left, closeTo(cleanedKitchenEarly.left, 1)); expect(cleanCoffee.height, closeTo(15 * 2.45, 0.5)); - expect(cleanedKitchenEarly.height, closeTo(15 * 2.45, 0.5)); - expect(cleanCoffee.bottom, lessThan(cleanedKitchenEarly.top)); + expect(cleanedKitchenCounter.height, closeTo(15 * 2.45, 0.5)); + expect( + data.cards + .singleWhere((card) => card.id == 'cleaned-kitchen-counter') + .startMinutes, + 8 * 60 + 15, + ); }); testWidgets('top bar controls scale inside a compact header width', ( @@ -345,6 +413,24 @@ void main() { expect(tester.getSize(find.byType(OutlinedButton)).width, lessThan(176)); }); + testWidgets('empty required banner does not reserve vertical space', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(480, 160)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: FocusFlowTheme.dark(), + home: const Scaffold( + body: Align(alignment: Alignment.topLeft, child: RequiredBanner()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.getSize(find.byType(RequiredBanner)).height, 0); + }); + testWidgets('task card controls scale inside a narrow task bubble', ( tester, ) async { @@ -373,12 +459,21 @@ void main() { tester.widget(find.byType(AnimatedOpacity)).opacity, 1, ); - - final button = tester.widget( - find.widgetWithIcon(IconButton, Icons.arrow_forward), + final actionBackground = tester.widget( + find.byType(AnimatedContainer), + ); + final decoration = actionBackground.decoration; + expect(decoration, isA()); + final boxDecoration = decoration! as BoxDecoration; + expect(boxDecoration.color, const Color(0xFF021A0C)); + expect(boxDecoration.border, isNull); + + final icon = tester.widget(find.byIcon(Icons.arrow_forward)); + expect(icon.size, lessThan(18)); + expect( + tester.getSize(find.byIcon(Icons.arrow_forward)).width, + lessThan(32), ); - expect(button.iconSize, lessThan(18)); - expect(button.constraints?.maxWidth, lessThan(32)); await gesture.removePointer(); }); @@ -401,9 +496,110 @@ void main() { final title = tester.getRect(find.text('Five minute reset')); final time = tester.getRect(find.text('5 min')); + final statusCircle = tester.getRect( + find.byKey(const ValueKey('short-inline-time-status')), + ); + expect(statusCircle.height, lessThan(20)); + expect(statusCircle.right, lessThan(title.left)); expect(title.right, lessThan(time.left)); expect((title.center.dy - time.center.dy).abs(), lessThan(2)); + expect(find.byType(RewardIcon), findsOneWidget); + expect(find.byType(DifficultyBars), findsOneWidget); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('short-inline-time')), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(AnimatedOpacity)).opacity, + 1, + ); + expect(find.text('5 min'), findsOneWidget); + + await gesture.removePointer(); + }); + + testWidgets('status circle toggles task completion without opening modal', ( + tester, + ) async { + final composition = _composition(); + await _pumpPlanApp(tester, composition: composition); + + await _scrollIntoView(tester, const ValueKey('clean-coffee-maker')); + final beforeClick = DateTime.now().toUtc(); + await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); + await tester.pumpAndSettle(); + final afterClick = DateTime.now().toUtc(); + + final completedTask = composition.store.currentTasks.singleWhere( + (task) => task.id == 'clean-coffee-maker', + ); + final scheduledStart = completedTask.scheduledStart; + final scheduledEnd = completedTask.scheduledEnd; + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect(completedTask.status, TaskStatus.completed); + expect(completedTask.completedAt, isNotNull); + expect(completedTask.completedAt!.isBefore(beforeClick), isFalse); + expect(completedTask.completedAt!.isAfter(afterClick), isFalse); + expect(find.textContaining('Completed at:'), findsWidgets); + + await tester.tap(find.byKey(const ValueKey('clean-coffee-maker-status'))); + await tester.pumpAndSettle(); + + final reopenedTask = composition.store.currentTasks.singleWhere( + (task) => task.id == 'clean-coffee-maker', + ); + expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing); + expect(reopenedTask.status, TaskStatus.planned); + expect(reopenedTask.scheduledStart, scheduledStart); + expect(reopenedTask.scheduledEnd, scheduledEnd); + expect(reopenedTask.actualStart, isNull); + expect(reopenedTask.actualEnd, isNull); + expect(reopenedTask.completedAt, isNull); + }); + + testWidgets('task modal status circle toggles and keeps details in sync', ( + tester, + ) async { + final composition = _composition(); + await _pumpPlanApp(tester, composition: composition); + + await _scrollIntoView(tester, const ValueKey('water-plants')); + await tester.tap(find.byKey(const ValueKey('water-plants'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); + expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget); + expect(find.textContaining('Completed -'), findsNothing); + + await tester.tap(find.byKey(const ValueKey('task-modal-status'))); + await tester.pumpAndSettle(); + + final completedTask = composition.store.currentTasks.singleWhere( + (task) => task.id == 'water-plants', + ); + expect(completedTask.status, TaskStatus.completed); + expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); + expect(find.text('Water plants'), findsWidgets); + expect(find.textContaining('Completed -'), findsOneWidget); + expect(find.text('Planned: 3:20 PM - 4:00 PM'), findsOneWidget); + expect(find.byIcon(Icons.check), findsWidgets); + + await tester.tap(find.byKey(const ValueKey('task-modal-status'))); + await tester.pumpAndSettle(); + + final reopenedTask = composition.store.currentTasks.singleWhere( + (task) => task.id == 'water-plants', + ); + expect(reopenedTask.status, TaskStatus.planned); + expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget); + expect(find.text('Flexible - 3:20 PM - 4:00 PM'), findsOneWidget); + expect(find.textContaining('Completed -'), findsNothing); }); testWidgets('free slot text scales inside a short task bubble', ( @@ -423,15 +619,21 @@ void main() { ); expect(tester.takeException(), isNull); + expect(find.byKey(const ValueKey('short-free-slot-status')), findsNothing); expect(find.text('Intentional rest'), findsOneWidget); expect(find.text('6:15 PM - 6:30 PM'), findsOneWidget); }); } -Future _pumpPlanApp(WidgetTester tester) async { +Future _pumpPlanApp( + WidgetTester tester, { + DemoSchedulerComposition? composition, +}) async { await tester.binding.setSurfaceSize(const Size(1586, 992)); addTearDown(() => tester.binding.setSurfaceSize(null)); - await tester.pumpWidget(FocusFlowApp(composition: _composition())); + await tester.pumpWidget( + FocusFlowApp(composition: composition ?? _composition()), + ); await tester.pumpAndSettle(); } @@ -473,7 +675,11 @@ Future _pumpTimelineCard( child: SizedBox( width: size.width, height: size.height, - child: TaskTimelineCard(card: card, onTap: () {}), + child: TaskTimelineCard( + card: card, + onTap: () {}, + onStatusPressed: () {}, + ), ), ), ), @@ -510,8 +716,19 @@ TimelineCardModel _timelineCard({ int startMinutes = 18 * 60, int endMinutes = 18 * 60 + 30, TaskVisualKind visualKind = TaskVisualKind.flexible, + TaskType? taskType, bool showsQuickActions = true, + String? completedTimeText, }) { + final resolvedTaskType = + taskType ?? + switch (visualKind) { + TaskVisualKind.flexible => TaskType.flexible, + TaskVisualKind.required => TaskType.critical, + TaskVisualKind.appointment => TaskType.inflexible, + TaskVisualKind.freeSlot => TaskType.freeSlot, + TaskVisualKind.completedSurprise => TaskType.flexible, + }; return TimelineCardModel( id: id, title: title, @@ -520,9 +737,11 @@ TimelineCardModel _timelineCard({ timeText: timeText, startMinutes: startMinutes, endMinutes: endMinutes, + taskType: resolvedTaskType, visualKind: visualKind, rewardIconToken: TimelineRewardIconToken.medium, difficultyIconToken: TimelineDifficultyIconToken.medium, + completedTimeText: completedTimeText, isCompleted: false, isSelectable: true, showsQuickActions: showsQuickActions, diff --git a/packages/scheduler_core/lib/src/application_commands.dart b/packages/scheduler_core/lib/src/application_commands.dart index fda6996..cf39363 100644 --- a/packages/scheduler_core/lib/src/application_commands.dart +++ b/packages/scheduler_core/lib/src/application_commands.dart @@ -28,6 +28,7 @@ enum ApplicationCommandCode { pushFlexibleToTomorrowTopOfQueue, moveFlexibleToBacklog, completeFlexibleTask, + uncompleteTask, applyRequiredTaskAction, logSurpriseTask, createProtectedFreeSlot, @@ -558,6 +559,63 @@ class V1ApplicationCommandUseCases { ); } + /// Mark a completed scheduled task as still needing to be done. + Future> uncompleteTask({ + required ApplicationOperationContext context, + required CivilDate localDate, + required String taskId, + DateTime? expectedUpdatedAt, + }) { + const commandCode = ApplicationCommandCode.uncompleteTask; + return applicationStore.run( + context: context, + operationName: commandCode.name, + action: (repositories) async { + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } + if (task == null) { + return _failure(ApplicationFailureCode.notFound, entityId: taskId); + } + final staleFailure = _staleFailure(task, expectedUpdatedAt); + if (staleFailure != null) { + return ApplicationResult.failure(staleFailure); + } + if (task.status != TaskStatus.completed || + (!task.isFlexible && !task.isRequiredVisible)) { + return _failure( + ApplicationFailureCode.conflict, + entityId: taskId, + detailCode: TaskTransitionOutcomeCode.invalidState.name, + ); + } + final uncompleted = task.copyWith( + status: TaskStatus.planned, + updatedAt: context.now, + clearActualInterval: true, + clearCompletion: true, + ); + return _persist( + repositories: repositories, + commandCode: commandCode, + context: context, + beforeTasks: state.tasks, + afterTasks: _replaceTask(state.tasks, uncompleted), + activities: const [], + readHint: ApplicationCommandReadHint( + localDate: localDate, + affectedTaskIds: [taskId], + ), + ); + }, + ); + } + /// Apply a required visible task lifecycle action. Future> applyRequiredTaskAction({ required ApplicationOperationContext context, diff --git a/packages/scheduler_core/lib/src/timeline_state.dart b/packages/scheduler_core/lib/src/timeline_state.dart index 1bb5e4f..4f463ab 100644 --- a/packages/scheduler_core/lib/src/timeline_state.dart +++ b/packages/scheduler_core/lib/src/timeline_state.dart @@ -76,6 +76,7 @@ class TimelineItem { required this.category, this.start, this.end, + this.completedAt, this.durationMinutes, }) : quickActions = List.unmodifiable(quickActions); @@ -109,6 +110,9 @@ class TimelineItem { /// Timeline placement end, if the source item has one. final DateTime? end; + /// Completion instant, if the source task has been completed. + final DateTime? completedAt; + /// Duration display value for flexible task cards. final int? durationMinutes; @@ -198,6 +202,7 @@ class TimelineItemMapper { showsExplicitTime: _showsExplicitTime(task.type), start: task.scheduledStart, end: task.scheduledEnd, + completedAt: task.completedAt, durationMinutes: _durationMinutesFor(task), quickActions: _quickActionsFor(task.type), category: task.isLocked diff --git a/packages/scheduler_core/test/application_commands_test.dart b/packages/scheduler_core/test/application_commands_test.dart index 8e7a624..bf93be9 100644 --- a/packages/scheduler_core/test/application_commands_test.dart +++ b/packages/scheduler_core/test/application_commands_test.dart @@ -311,6 +311,43 @@ void main() { expect(store.currentProjectStatistics.single.completedTaskCount, 1); }); + test('uncomplete task clears completion while preserving schedule', + () async { + final completed = task( + id: 'completed', + title: 'Completed', + type: TaskType.flexible, + status: TaskStatus.completed, + createdAt: createdAt, + start: DateTime.utc(2026, 6, 25, 9), + end: DateTime.utc(2026, 6, 25, 9, 30), + actualStart: DateTime.utc(2026, 6, 25, 9), + actualEnd: DateTime.utc(2026, 6, 25, 9, 20), + completedAt: DateTime.utc(2026, 6, 25, 9, 20), + ); + final store = storeWithSettings(tasks: [completed]); + + final result = await commands(store).uncompleteTask( + context: appContext( + 'uncomplete-task', + DateTime.utc(2026, 6, 25, 9, 25), + ), + localDate: day, + taskId: completed.id, + ); + + expect(result.isSuccess, isTrue); + final reopened = taskById(store.currentTasks, completed.id); + expect(reopened.status, TaskStatus.planned); + expect(reopened.scheduledStart, completed.scheduledStart); + expect(reopened.scheduledEnd, completed.scheduledEnd); + expect(reopened.actualStart, isNull); + expect(reopened.actualEnd, isNull); + expect(reopened.completedAt, isNull); + expect(reopened.updatedAt, DateTime.utc(2026, 6, 25, 9, 25)); + expect(store.currentTaskActivities, isEmpty); + }); + test('surprise logging creates completed work and repairs flexible overlap', () async { final planned = task( @@ -525,6 +562,9 @@ Task task({ required DateTime createdAt, DateTime? start, DateTime? end, + DateTime? actualStart, + DateTime? actualEnd, + DateTime? completedAt, int? durationMinutes, String projectId = 'home', String? parentTaskId, @@ -539,6 +579,9 @@ Task task({ (start != null && end != null ? end.difference(start).inMinutes : 30), scheduledStart: start, scheduledEnd: end, + actualStart: actualStart, + actualEnd: actualEnd, + completedAt: completedAt, parentTaskId: parentTaskId, createdAt: createdAt, updatedAt: createdAt,