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: #14
This commit is contained in:
pyr0ball 2026-07-07 13:45:04 -07:00
parent c357b7d97d
commit c730c33569
13 changed files with 191 additions and 13 deletions

View file

@ -103,7 +103,7 @@ boundary.
- Compact/Normal toggle. - Compact/Normal toggle.
- Settings button. - Settings button.
- Timeline card action icons. - Timeline card Schedule and Protect time icons.
## Persistence Proof ## Persistence Proof
@ -170,6 +170,4 @@ Run these from `apps/focus_flow_flutter/`.
has no compact-mode concept yet; see Forgejo issue #11). has no compact-mode concept yet; see Forgejo issue #11).
- Add a real Settings screen bound to existing `OwnerSettings`/ - Add a real Settings screen bound to existing `OwnerSettings`/
`BacklogStalenessSettings` persistence. `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. - Add Shield/Recovery only in a later scope.

View file

@ -103,6 +103,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
onPushToNext: widget.onPushToNext, onPushToNext: widget.onPushToNext,
onPushToTomorrow: widget.onPushToTomorrow, onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog, onPushToBacklog: widget.onPushToBacklog,
onBreakUpCard: widget.onBreakUpCard,
now: () => now: () =>
DateTime.now().toUtc().add(widget.data.timeZoneOffset), DateTime.now().toUtc().add(widget.data.timeZoneOffset),
), ),

View file

@ -1,9 +1,15 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors // SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only // 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 { class _BreakUpRow {
/// Creates an empty break-up row with fresh text controllers. /// Creates an empty break-up row with fresh text controllers.
_BreakUpRow() _BreakUpRow()
@ -31,19 +37,22 @@ class _BreakUpRow {
/// Dialog that collects one or more child task drafts to break up a parent /// 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). /// 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. /// Creates a break-up dialog.
const _BreakUpDialog(); const BreakUpDialog({super.key});
/// Creates the mutable state object used by this stateful widget. /// 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. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override @override
State<_BreakUpDialog> createState() => _BreakUpDialogState(); State<BreakUpDialog> createState() => _BreakUpDialogState();
} }
/// Private implementation type for `_BreakUpDialogState` in this library. /// Private implementation type for `_BreakUpDialogState` in this library.
/// It owns the growable list of child-task rows shown by the dialog. /// It owns the growable list of child-task rows shown by the dialog.
class _BreakUpDialogState extends State<_BreakUpDialog> { class _BreakUpDialogState extends State<BreakUpDialog> {
/// Editable rows currently shown in the dialog. /// Editable rows currently shown in the dialog.
final List<_BreakUpRow> _rows = [_BreakUpRow()]; final List<_BreakUpRow> _rows = [_BreakUpRow()];

View file

@ -11,12 +11,12 @@ import 'package:scheduler_core/scheduler_core.dart';
import '../models/today_screen_models.dart'; import '../models/today_screen_models.dart';
import '../theme/focus_flow_tokens.dart'; import '../theme/focus_flow_tokens.dart';
import 'dialogs/break_up_dialog.dart';
import 'status_circle_button.dart'; import 'status_circle_button.dart';
import 'timeline/difficulty_bars.dart'; import 'timeline/difficulty_bars.dart';
import 'timeline/reward_icon.dart'; import 'timeline/reward_icon.dart';
part 'task_selection/action_button.dart'; part 'task_selection/action_button.dart';
part 'task_selection/break_up_dialog.dart';
part 'task_selection/header_metadata.dart'; part 'task_selection/header_metadata.dart';
part 'task_selection/info_tile.dart'; part 'task_selection/info_tile.dart';
part 'task_selection/modal_accent.dart'; part 'task_selection/modal_accent.dart';
@ -70,7 +70,7 @@ class TaskSelectionModal extends StatelessWidget {
Future<void> _handleBreakUp(BuildContext context) async { Future<void> _handleBreakUp(BuildContext context) async {
final drafts = await showDialog<List<ApplicationChildTaskDraft>>( final drafts = await showDialog<List<ApplicationChildTaskDraft>>(
context: context, context: context,
builder: (_) => const _BreakUpDialog(), builder: (_) => const BreakUpDialog(),
); );
if (drafts == null || drafts.isEmpty) { if (drafts == null || drafts.isEmpty) {
return; return;

View file

@ -114,7 +114,7 @@ class _CardMetrics {
/// Returns the derived `actionsWidth` value for this object. /// 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. /// 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. /// 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. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.

View file

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

View file

@ -18,6 +18,7 @@ class _QuickActions extends StatelessWidget {
this.onPushToNext, this.onPushToNext,
this.onPushToTomorrow, this.onPushToTomorrow,
this.onPushToBacklog, this.onPushToBacklog,
this.onBreakUp,
}); });
/// Stores the `card` value for this object. /// Stores the `card` value for this object.
@ -53,6 +54,9 @@ class _QuickActions extends StatelessWidget {
/// Callback invoked when Push to backlog is selected. /// Callback invoked when Push to backlog is selected.
final Future<void> Function()? onPushToBacklog; final Future<void> Function()? onPushToBacklog;
/// Callback invoked when Break up is selected.
final Future<void> Function()? onBreakUp;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -90,6 +94,20 @@ class _QuickActions extends StatelessWidget {
onPushToTomorrow: onPushToTomorrow, onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog, 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( _NoOpIcon(
icon: Icons.calendar_today, icon: Icons.calendar_today,
tooltip: 'Schedule', tooltip: 'Schedule',

View file

@ -62,6 +62,9 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
onPushToBacklog: widget.onPushToBacklog == null onPushToBacklog: widget.onPushToBacklog == null
? null ? null
: () => _runPush(widget.onPushToBacklog!), : () => _runPush(widget.onPushToBacklog!),
onBreakUp: widget.onBreakUpCard == null
? null
: () => _handleBreakUp(context),
), ),
), ),
], ],
@ -111,6 +114,25 @@ class _TaskTimelineCardState extends State<TaskTimelineCard> {
); );
} }
/// Opens the break-up dialog and forwards drafted child tasks to
/// [TaskTimelineCard.onBreakUpCard] when the user confirms with at least
/// one task.
Future<void> _handleBreakUp(BuildContext context) async {
final drafts = await showDialog<List<ApplicationChildTaskDraft>>(
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. /// 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. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
Future<void> _runPush( Future<void> _runPush(

View file

@ -17,6 +17,7 @@ class _TrailingControls extends StatelessWidget {
this.onPushToNext, this.onPushToNext,
this.onPushToTomorrow, this.onPushToTomorrow,
this.onPushToBacklog, this.onPushToBacklog,
this.onBreakUp,
}); });
/// Stores the `card` value for this object. /// Stores the `card` value for this object.
@ -48,6 +49,9 @@ class _TrailingControls extends StatelessWidget {
/// Callback invoked when Push to backlog is selected. /// Callback invoked when Push to backlog is selected.
final Future<void> Function()? onPushToBacklog; final Future<void> Function()? onPushToBacklog;
/// Callback invoked when Break up is selected.
final Future<void> Function()? onBreakUp;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@override @override
@ -66,6 +70,7 @@ class _TrailingControls extends StatelessWidget {
onPushToNext: onPushToNext, onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow, onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog, onPushToBacklog: onPushToBacklog,
onBreakUp: onBreakUp,
), ),
if (card.showPastTaskPushButton) ...[ if (card.showPastTaskPushButton) ...[
_PastTaskPushButton( _PastTaskPushButton(

View file

@ -8,10 +8,12 @@ import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; 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 '../../models/today_screen_models.dart';
import '../../theme/focus_flow_tokens.dart'; import '../../theme/focus_flow_tokens.dart';
import '../dialogs/break_up_dialog.dart';
import '../status_circle_button.dart'; import '../status_circle_button.dart';
import 'difficulty_bars.dart'; import 'difficulty_bars.dart';
import 'reward_icon.dart'; import 'reward_icon.dart';
@ -37,6 +39,7 @@ class TaskTimelineCard extends StatefulWidget {
this.onPushToNext, this.onPushToNext,
this.onPushToTomorrow, this.onPushToTomorrow,
this.onPushToBacklog, this.onPushToBacklog,
this.onBreakUpCard,
super.key, super.key,
}); });
@ -58,6 +61,13 @@ class TaskTimelineCard extends StatefulWidget {
/// Callback invoked when the card requests Push to backlog. /// Callback invoked when the card requests Push to backlog.
final Future<void> Function(TimelineCardModel card)? onPushToBacklog; final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
/// Callback invoked with drafted child tasks after Break up is confirmed.
final Future<void> Function(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
)?
onBreakUpCard;
/// Creates the mutable state object used by this stateful widget. /// 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. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override @override

View file

@ -6,6 +6,8 @@ library;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart'
show ApplicationChildTaskDraft;
import '../../models/today_screen_models.dart'; import '../../models/today_screen_models.dart';
import '../../theme/focus_flow_tokens.dart'; import '../../theme/focus_flow_tokens.dart';
@ -29,6 +31,7 @@ class TimelineView extends StatefulWidget {
this.onPushToNext, this.onPushToNext,
this.onPushToTomorrow, this.onPushToTomorrow,
this.onPushToBacklog, this.onPushToBacklog,
this.onBreakUpCard,
this.now, this.now,
this.scrollTargetCardId, this.scrollTargetCardId,
this.scrollRequest = 0, this.scrollRequest = 0,
@ -60,6 +63,14 @@ class TimelineView extends StatefulWidget {
/// Callback invoked when a task card requests Push to backlog. /// Callback invoked when a task card requests Push to backlog.
final Future<void> Function(TimelineCardModel card)? onPushToBacklog; final Future<void> Function(TimelineCardModel card)? onPushToBacklog;
/// Callback invoked with drafted child tasks after a task card's Break up
/// action is confirmed.
final Future<void> Function(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
)?
onBreakUpCard;
/// Clock used to center the initial scroll position. /// Clock used to center the initial scroll position.
final DateTime Function()? now; final DateTime Function()? now;

View file

@ -148,6 +148,7 @@ class _TimelineViewState extends State<TimelineView> {
onPushToNext: widget.onPushToNext, onPushToNext: widget.onPushToNext,
onPushToTomorrow: widget.onPushToTomorrow, onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog, onPushToBacklog: widget.onPushToBacklog,
onBreakUpCard: widget.onBreakUpCard,
), ),
), ),
), ),

View file

@ -883,6 +883,51 @@ void main() {
await gesture.removePointer(); await gesture.removePointer();
}); });
testWidgets(
'hover Break up icon opens the same break-up dialog as the task modal',
(tester) async {
TimelineCardModel? brokenUpCard;
List<ApplicationChildTaskDraft>? 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', ( testWidgets('Push menu dismisses on outside click without callback', (
tester, tester,
) async { ) async {
@ -1236,6 +1281,11 @@ Future<void> _pumpTimelineCard(
Future<void> Function(TimelineCardModel card)? onPushToNext, Future<void> Function(TimelineCardModel card)? onPushToNext,
Future<void> Function(TimelineCardModel card)? onPushToTomorrow, Future<void> Function(TimelineCardModel card)? onPushToTomorrow,
Future<void> Function(TimelineCardModel card)? onPushToBacklog, Future<void> Function(TimelineCardModel card)? onPushToBacklog,
Future<void> Function(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
)?
onBreakUpCard,
}) async { }) async {
await tester.binding.setSurfaceSize( await tester.binding.setSurfaceSize(
Size(size.width + 120, size.height + 220), Size(size.width + 120, size.height + 220),
@ -1255,6 +1305,7 @@ Future<void> _pumpTimelineCard(
onPushToNext: onPushToNext, onPushToNext: onPushToNext,
onPushToTomorrow: onPushToTomorrow, onPushToTomorrow: onPushToTomorrow,
onPushToBacklog: onPushToBacklog, onPushToBacklog: onPushToBacklog,
onBreakUpCard: onBreakUpCard,
), ),
), ),
), ),