From c730c33569dcd623bfb6f57b8f1aac3ba9b3a132 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 7 Jul 2026 13:45:04 -0700 Subject: [PATCH] feat(ui): wire timeline card Break up quick action Extracts the break-up dialog into a shared widgets/dialogs library so the task modal and timeline card quick actions reuse the same flow instead of duplicating it, then wires a Break up icon through TimelineView/TaskTimelineCard to the existing breakUpTask command path. Closes: https://git.opensourcesolarpunk.com/eva/focus-flow/issues/14 --- apps/focus_flow_flutter/README.md | 4 +- .../app/home/today_screen_scaffold_state.dart | 1 + .../break_up_dialog.dart | 21 +++++--- .../lib/widgets/task_selection_modal.dart | 4 +- .../timeline/task_card/card_metrics.dart | 2 +- .../timeline/task_card/no_op_icon.dart | 52 +++++++++++++++++++ .../timeline/task_card/quick_actions.dart | 18 +++++++ .../task_card/task_timeline_card_state.dart | 22 ++++++++ .../timeline/task_card/trailing_controls.dart | 5 ++ .../widgets/timeline/task_timeline_card.dart | 12 ++++- .../lib/widgets/timeline/timeline_view.dart | 11 ++++ .../timeline_view/timeline_view_state.dart | 1 + apps/focus_flow_flutter/test/widget_test.dart | 51 ++++++++++++++++++ 13 files changed, 191 insertions(+), 13 deletions(-) rename apps/focus_flow_flutter/lib/widgets/{task_selection => dialogs}/break_up_dialog.dart (92%) diff --git a/apps/focus_flow_flutter/README.md b/apps/focus_flow_flutter/README.md index 5b59146..4f95e1e 100644 --- a/apps/focus_flow_flutter/README.md +++ b/apps/focus_flow_flutter/README.md @@ -103,7 +103,7 @@ boundary. - Compact/Normal toggle. - Settings button. -- Timeline card action icons. +- Timeline card Schedule and Protect time icons. ## Persistence Proof @@ -170,6 +170,4 @@ Run these from `apps/focus_flow_flutter/`. has no compact-mode concept yet; see Forgejo issue #11). - Add a real Settings screen bound to existing `OwnerSettings`/ `BacklogStalenessSettings` persistence. -- Wire timeline card quick-action icons to the same commands the task modal - already uses. - Add Shield/Recovery only in a later scope. 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 index 75100bc..8fac12b 100644 --- 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 @@ -103,6 +103,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { onPushToNext: widget.onPushToNext, onPushToTomorrow: widget.onPushToTomorrow, onPushToBacklog: widget.onPushToBacklog, + onBreakUpCard: widget.onBreakUpCard, now: () => DateTime.now().toUtc().add(widget.data.timeZoneOffset), ), diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/break_up_dialog.dart b/apps/focus_flow_flutter/lib/widgets/dialogs/break_up_dialog.dart similarity index 92% rename from apps/focus_flow_flutter/lib/widgets/task_selection/break_up_dialog.dart rename to apps/focus_flow_flutter/lib/widgets/dialogs/break_up_dialog.dart index 97f4cad..aa67cdb 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection/break_up_dialog.dart +++ b/apps/focus_flow_flutter/lib/widgets/dialogs/break_up_dialog.dart @@ -1,9 +1,15 @@ // SPDX-FileCopyrightText: 2026 FocusFlow contributors // SPDX-License-Identifier: AGPL-3.0-only -part of '../task_selection_modal.dart'; +/// Renders the shared Break Up dialog for the FocusFlow Flutter UI. +library; -/// Editable child-task row state owned by [_BreakUpDialog]. +import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart'; + +import '../../theme/focus_flow_tokens.dart'; + +/// Editable child-task row state owned by [BreakUpDialog]. class _BreakUpRow { /// Creates an empty break-up row with fresh text controllers. _BreakUpRow() @@ -31,19 +37,22 @@ class _BreakUpRow { /// Dialog that collects one or more child task drafts to break up a parent /// task, per MVP-AC-11 (row-level priority, reward, and duration). -class _BreakUpDialog extends StatefulWidget { +/// +/// Shared by the task selection modal and timeline card quick actions so +/// both surfaces reuse the same break-up flow instead of duplicating it. +class BreakUpDialog extends StatefulWidget { /// Creates a break-up dialog. - const _BreakUpDialog(); + const BreakUpDialog({super.key}); /// Creates the mutable state object used by this stateful widget. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. @override - State<_BreakUpDialog> createState() => _BreakUpDialogState(); + State createState() => _BreakUpDialogState(); } /// Private implementation type for `_BreakUpDialogState` in this library. /// It owns the growable list of child-task rows shown by the dialog. -class _BreakUpDialogState extends State<_BreakUpDialog> { +class _BreakUpDialogState extends State { /// Editable rows currently shown in the dialog. final List<_BreakUpRow> _rows = [_BreakUpRow()]; 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 28a04d2..12592a2 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -11,12 +11,12 @@ import 'package:scheduler_core/scheduler_core.dart'; import '../models/today_screen_models.dart'; import '../theme/focus_flow_tokens.dart'; +import 'dialogs/break_up_dialog.dart'; import 'status_circle_button.dart'; import 'timeline/difficulty_bars.dart'; import 'timeline/reward_icon.dart'; part 'task_selection/action_button.dart'; -part 'task_selection/break_up_dialog.dart'; part 'task_selection/header_metadata.dart'; part 'task_selection/info_tile.dart'; part 'task_selection/modal_accent.dart'; @@ -70,7 +70,7 @@ class TaskSelectionModal extends StatelessWidget { Future _handleBreakUp(BuildContext context) async { final drafts = await showDialog>( context: context, - builder: (_) => const _BreakUpDialog(), + builder: (_) => const BreakUpDialog(), ); if (drafts == null || drafts.isEmpty) { return; 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 index 9615d88..af82fe0 100644 --- 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 @@ -114,7 +114,7 @@ class _CardMetrics { /// Returns the derived `actionsWidth` value for this object. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. - double get actionsWidth => actionButtonSize * 3 + actionGap; + double get actionsWidth => actionButtonSize * 4 + actionGap; /// Returns the derived `pushButtonHeight` value for this object. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated. 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 index 4be5113..8dd5b10 100644 --- 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 @@ -124,3 +124,55 @@ class _QuickPushIcon extends StatelessWidget { ); } } + +/// Private implementation type for `_QuickBreakUpIcon` in this library. +/// It exposes the same break-up dialog flow as the task selection modal from +/// the timeline card's compact hover actions. +class _QuickBreakUpIcon extends StatelessWidget { + /// Creates a `_QuickBreakUpIcon` instance with the values required by its invariants. + /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code. + const _QuickBreakUpIcon({ + required this.card, + required this.color, + required this.metrics, + this.onBreakUp, + }); + + /// Stores the `card` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final TimelineCardModel card; + + /// Stores the `color` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final Color color; + + /// Stores the `metrics` value for this object. + /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. + final _CardMetrics metrics; + + /// Callback invoked when Break up is selected. + final Future Function()? onBreakUp; + + /// Builds the widget subtree for this component. + /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. + @override + Widget build(BuildContext context) { + final enabled = onBreakUp != null; + return Tooltip( + message: 'Break up', + child: Semantics( + button: true, + enabled: enabled, + label: 'Break up ${card.title}', + child: GestureDetector( + onTap: enabled ? () => unawaited(onBreakUp!()) : null, + behavior: HitTestBehavior.opaque, + child: SizedBox.square( + dimension: metrics.actionButtonSize, + child: Icon(Icons.apps, 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 index 059f05b..713537c 100644 --- 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 @@ -18,6 +18,7 @@ class _QuickActions extends StatelessWidget { this.onPushToNext, this.onPushToTomorrow, this.onPushToBacklog, + this.onBreakUp, }); /// Stores the `card` value for this object. @@ -53,6 +54,9 @@ class _QuickActions extends StatelessWidget { /// Callback invoked when Push to backlog is selected. final Future Function()? onPushToBacklog; + /// Callback invoked when Break up is selected. + final Future Function()? onBreakUp; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -90,6 +94,20 @@ class _QuickActions extends StatelessWidget { onPushToTomorrow: onPushToTomorrow, onPushToBacklog: onPushToBacklog, ), + if (onBreakUp != null) + _QuickBreakUpIcon( + card: card, + color: color, + metrics: metrics, + onBreakUp: onBreakUp, + ) + else + _NoOpIcon( + icon: Icons.apps, + tooltip: 'Break up', + color: color, + metrics: metrics, + ), _NoOpIcon( icon: Icons.calendar_today, tooltip: 'Schedule', 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 index 29eb8ed..f9f8e07 100644 --- 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 @@ -62,6 +62,9 @@ class _TaskTimelineCardState extends State { onPushToBacklog: widget.onPushToBacklog == null ? null : () => _runPush(widget.onPushToBacklog!), + onBreakUp: widget.onBreakUpCard == null + ? null + : () => _handleBreakUp(context), ), ), ], @@ -111,6 +114,25 @@ class _TaskTimelineCardState extends State { ); } + /// Opens the break-up dialog and forwards drafted child tasks to + /// [TaskTimelineCard.onBreakUpCard] when the user confirms with at least + /// one task. + Future _handleBreakUp(BuildContext context) async { + final drafts = await showDialog>( + context: context, + builder: (_) => const BreakUpDialog(), + ); + if (drafts == null || drafts.isEmpty) { + return; + } + logger.debug( + () => + 'Card break-up requested. cardId=${widget.card.id} ' + 'childCount=${drafts.length}', + ); + await widget.onBreakUpCard!(widget.card, drafts); + } + /// Runs the `_runPush` operation and completes after its asynchronous work finishes. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order. Future _runPush( 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 index ba78fd4..d28cfdc 100644 --- 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 @@ -17,6 +17,7 @@ class _TrailingControls extends StatelessWidget { this.onPushToNext, this.onPushToTomorrow, this.onPushToBacklog, + this.onBreakUp, }); /// Stores the `card` value for this object. @@ -48,6 +49,9 @@ class _TrailingControls extends StatelessWidget { /// Callback invoked when Push to backlog is selected. final Future Function()? onPushToBacklog; + /// Callback invoked when Break up is selected. + final Future Function()? onBreakUp; + /// Builds the widget subtree for this component. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. @override @@ -66,6 +70,7 @@ class _TrailingControls extends StatelessWidget { onPushToNext: onPushToNext, onPushToTomorrow: onPushToTomorrow, onPushToBacklog: onPushToBacklog, + onBreakUp: onBreakUp, ), if (card.showPastTaskPushButton) ...[ _PastTaskPushButton( 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 937d1d2..1717dd6 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 @@ -8,10 +8,12 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:scheduler_core/scheduler_core.dart' show logger; +import 'package:scheduler_core/scheduler_core.dart' + show ApplicationChildTaskDraft, logger; import '../../models/today_screen_models.dart'; import '../../theme/focus_flow_tokens.dart'; +import '../dialogs/break_up_dialog.dart'; import '../status_circle_button.dart'; import 'difficulty_bars.dart'; import 'reward_icon.dart'; @@ -37,6 +39,7 @@ class TaskTimelineCard extends StatefulWidget { this.onPushToNext, this.onPushToTomorrow, this.onPushToBacklog, + this.onBreakUpCard, super.key, }); @@ -58,6 +61,13 @@ class TaskTimelineCard extends StatefulWidget { /// Callback invoked when the card requests Push to backlog. final Future Function(TimelineCardModel card)? onPushToBacklog; + /// Callback invoked with drafted child tasks after Break up is confirmed. + final Future Function( + TimelineCardModel card, + List children, + )? + onBreakUpCard; + /// Creates the mutable state object used by this stateful widget. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin. @override 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 d9d3fb3..094cf15 100644 --- a/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart +++ b/apps/focus_flow_flutter/lib/widgets/timeline/timeline_view.dart @@ -6,6 +6,8 @@ library; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:scheduler_core/scheduler_core.dart' + show ApplicationChildTaskDraft; import '../../models/today_screen_models.dart'; import '../../theme/focus_flow_tokens.dart'; @@ -29,6 +31,7 @@ class TimelineView extends StatefulWidget { this.onPushToNext, this.onPushToTomorrow, this.onPushToBacklog, + this.onBreakUpCard, this.now, this.scrollTargetCardId, this.scrollRequest = 0, @@ -60,6 +63,14 @@ class TimelineView extends StatefulWidget { /// Callback invoked when a task card requests Push to backlog. final Future Function(TimelineCardModel card)? onPushToBacklog; + /// Callback invoked with drafted child tasks after a task card's Break up + /// action is confirmed. + final Future Function( + TimelineCardModel card, + List children, + )? + onBreakUpCard; + /// Clock used to center the initial scroll position. final DateTime Function()? now; 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 index b850032..3fa792f 100644 --- 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 @@ -148,6 +148,7 @@ class _TimelineViewState extends State { onPushToNext: widget.onPushToNext, onPushToTomorrow: widget.onPushToTomorrow, onPushToBacklog: widget.onPushToBacklog, + onBreakUpCard: widget.onBreakUpCard, ), ), ), diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 6d50617..9402b56 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -883,6 +883,51 @@ void main() { await gesture.removePointer(); }); + testWidgets( + 'hover Break up icon opens the same break-up dialog as the task modal', + (tester) async { + TimelineCardModel? brokenUpCard; + List? brokenUpChildren; + + await _pumpTimelineCard( + tester, + card: _timelineCard(id: 'quick-break-up', title: 'Current task'), + size: const Size(420, 88), + onBreakUpCard: (card, children) async { + brokenUpCard = card; + brokenUpChildren = children; + }, + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer( + location: tester.getCenter( + find.byKey(const ValueKey('quick-break-up')), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.apps)); + await tester.pumpAndSettle(); + + expect(find.text('Break up task'), findsOneWidget); + + await tester.enterText( + find.byKey(const ValueKey('break-up-row-0-title')), + 'Child task', + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('break-up-confirm'))); + await tester.pumpAndSettle(); + + expect(brokenUpCard?.id, 'quick-break-up'); + expect(brokenUpChildren, hasLength(1)); + expect(brokenUpChildren!.single.title, 'Child task'); + + await gesture.removePointer(); + }, + ); + testWidgets('Push menu dismisses on outside click without callback', ( tester, ) async { @@ -1236,6 +1281,11 @@ Future _pumpTimelineCard( Future Function(TimelineCardModel card)? onPushToNext, Future Function(TimelineCardModel card)? onPushToTomorrow, Future Function(TimelineCardModel card)? onPushToBacklog, + Future Function( + TimelineCardModel card, + List children, + )? + onBreakUpCard, }) async { await tester.binding.setSurfaceSize( Size(size.width + 120, size.height + 220), @@ -1255,6 +1305,7 @@ Future _pumpTimelineCard( onPushToNext: onPushToNext, onPushToTomorrow: onPushToTomorrow, onPushToBacklog: onPushToBacklog, + onBreakUpCard: onBreakUpCard, ), ), ),