feat(ui): wire sidebar Backlog nav and modal Move-to-Backlog/Break-up actions
Some checks failed
CI / test (macos-latest) (push) Has been cancelled
CI / test (ubuntu-latest) (push) Has been cancelled
CI / test (windows-latest) (push) Has been cancelled
CI / package (macos-latest, macos) (push) Has been cancelled
CI / package (ubuntu-latest, linux) (push) Has been cancelled
CI / package (windows-latest, windows) (push) Has been cancelled
CI / test (macos-latest) (pull_request) Has been cancelled
CI / test (ubuntu-latest) (pull_request) Has been cancelled
CI / test (windows-latest) (pull_request) Has been cancelled
CI / package (macos-latest, macos) (pull_request) Has been cancelled
CI / package (ubuntu-latest, linux) (pull_request) Has been cancelled
CI / package (windows-latest, windows) (pull_request) Has been cancelled

Backend was already V1-complete for these paths (moveFlexibleToBacklog,
breakUpTask); this closes the UI-wiring gap identified in the V1
feature-complete audit.

- Add createBacklogController() to SchedulerAppComposition and implement it
  in PersistentSchedulerComposition (only the demo composition had one).
- Sidebar now switches the main content area between Today and Backlog via
  a real SidebarScreen enum instead of hardcoded onTap: () {} stubs.
- Task modal's "Backlog" quick-action reuses the existing onPushToBacklog
  wiring (same backend command as the Push menu's "Push to backlog").
- Add a break-up dialog (row-level title/priority/reward/duration per
  MVP-AC-11) and SchedulerCommandController.breakUpTask(), wired to the
  modal's "Break up" button.
- Fix a latent bug where the active sidebar nav item's key was hardcoded
  to nav-today-active regardless of which item was active.

Closes: Circuit-Forge/focus-flow#9
Closes: Circuit-Forge/focus-flow#12
Closes: Circuit-Forge/focus-flow#13
This commit is contained in:
pyr0ball 2026-07-06 01:46:59 -07:00
parent a488d1a5f5
commit c357b7d97d
13 changed files with 565 additions and 102 deletions

View file

@ -12,9 +12,8 @@ on-disk SQLite database and bootstraps owner settings plus the default Home
project when they are missing.
The compact Today screen can toggle completion for currently rendered task
cards. Backlog widgets and quick-capture controls exist for tests and focused
composition paths, but the sidebar Backlog navigation remains out of scope for
this screen.
cards. The sidebar switches between the Today and Backlog screens, and the
Backlog screen supports quick capture and scheduling backlog items.
## Runtime Data
@ -102,13 +101,9 @@ boundary.
## Intentionally No-op
- Sidebar navigation.
- Date navigation and calendar buttons.
- Compact/Normal toggle.
- Settings button.
- Show upcoming.
- Timeline card action icons.
- Modal Push, Move to Backlog, and Break up buttons.
## Persistence Proof
@ -171,7 +166,10 @@ Run these from `apps/focus_flow_flutter/`.
## Next Plans
- Wire visible Backlog navigation and quick-capture UI into the main shell.
- Wire functional Push, Move to Backlog, and Break up actions.
- Add real navigation/settings.
- Give the Compact/Normal toggle real backing state (the live Today pipeline
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.

View file

@ -177,6 +177,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
}
/// Creates a generic read controller for backlog panes.
@override
UiReadController<BacklogQueryResult> createBacklogController() {
return ApplicationReadController<BacklogQueryResult>(
read: () {

View file

@ -10,12 +10,14 @@ import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart';
import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart';
import '../models/today_screen_models.dart';
import '../theme/focus_flow_theme.dart';
import '../theme/focus_flow_tokens.dart';
import '../widgets/app_shell.dart';
import '../widgets/backlog_pane.dart';
import '../widgets/required_banner.dart';
import '../widgets/sidebar.dart';
import '../widgets/task_selection_modal.dart';

View file

@ -18,6 +18,13 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
late final SchedulerCommandController commandController;
/// Stores the `backlogController` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
late final UiReadController<BacklogQueryResult> backlogController;
/// Screen currently shown in the main content area.
var _activeScreen = SidebarScreen.today;
/// Initializes state owned by this object before the first build.
/// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
@override
@ -28,12 +35,15 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
controller = widget.composition.createTodayScreenController(
selectedDates: selectedDateController,
);
backlogController = widget.composition.createBacklogController();
commandController = widget.composition.createCommandController(
refreshReads: controller.load,
refreshReads: _refreshReads,
selectedDate: () => selectedDateController.date,
);
logger.finer(() => 'FocusFlow home triggering initial Today load.');
controller.load();
logger.finer(() => 'FocusFlow home triggering initial Backlog load.');
unawaited(backlogController.load());
}
/// Releases resources owned by this object before it leaves the tree.
@ -43,11 +53,38 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
logger.debug(() => 'FocusFlow home disposing controllers.');
commandController.dispose();
controller.dispose();
backlogController.dispose();
selectedDateController.dispose();
unawaited(widget.composition.dispose());
super.dispose();
}
/// Reloads every read surface a command may have affected, regardless of
/// which screen is currently visible, so switching screens never shows
/// stale data.
Future<void> _refreshReads() async {
await controller.load();
await backlogController.load();
}
/// Switches the main content area to the Today screen.
void _showToday() {
if (_activeScreen == SidebarScreen.today) {
return;
}
logger.finer(() => 'Sidebar navigation selected Today.');
setState(() => _activeScreen = SidebarScreen.today);
}
/// Switches the main content area to the Backlog screen.
void _showBacklog() {
if (_activeScreen == SidebarScreen.backlog) {
return;
}
logger.finer(() => 'Sidebar navigation selected Backlog.');
setState(() => _activeScreen = SidebarScreen.backlog);
}
/// Runs the `_toggleCardCompletion` 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<void> _toggleCardCompletion(TimelineCardModel card) async {
@ -117,6 +154,23 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
controller.clearSelection();
}
/// Breaks [card] up into the drafted [children] tasks.
Future<void> _breakUpCard(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
) async {
logger.debug(
() =>
'Timeline break-up requested. cardId=${card.id} '
'childCount=${children.length}',
);
await commandController.breakUpTask(
parentTaskId: card.id,
children: children,
expectedUpdatedAt: card.expectedUpdatedAt,
);
}
/// Moves the visible planning date backward by one day.
void _selectPreviousDate() {
logger.finer(() => 'Previous date action requested.');
@ -156,58 +210,73 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
return Scaffold(
backgroundColor: FocusFlowTokens.appBackground,
body: AppShell(
sidebar: const Sidebar(),
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
return switch (controller.state) {
TodayScreenLoading() => const Center(
child: CircularProgressIndicator(),
),
TodayScreenFailure(:final message) => _PlanIssue(
message: message,
),
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
unawaited(_chooseDate());
},
),
TodayScreenReady(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
unawaited(_chooseDate());
},
),
};
},
sidebar: Sidebar(
activeScreen: _activeScreen,
onSelectToday: _showToday,
onSelectBacklog: _showBacklog,
),
child: switch (_activeScreen) {
SidebarScreen.today => _buildTodayContent(context),
SidebarScreen.backlog => BacklogPane(
controller: backlogController,
commandController: commandController,
),
},
),
);
}
/// Builds the compact Today timeline content for the main content area.
Widget _buildTodayContent(BuildContext context) {
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
return switch (controller.state) {
TodayScreenLoading() => const Center(
child: CircularProgressIndicator(),
),
TodayScreenFailure(:final message) => _PlanIssue(message: message),
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onBreakUpCard: _breakUpCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
unawaited(_chooseDate());
},
),
TodayScreenReady(:final data) => _TodayScreenScaffold(
data: data,
selectedCard: controller.selectedCard,
onSelectCard: controller.selectCard,
onCompleteCard: _toggleCardCompletion,
onPushToNext: _pushCardToNext,
onPushToTomorrow: _pushCardToTomorrow,
onPushToBacklog: _pushCardToBacklog,
onRemoveCard: _removeCard,
onBreakUpCard: _breakUpCard,
onAddTask: _addGenericFlexibleTask,
onClearSelection: controller.clearSelection,
onPreviousDate: _selectPreviousDate,
onNextDate: _selectNextDate,
onChooseDate: () {
unawaited(_chooseDate());
},
),
};
},
);
}
/// Builds the dark theme wrapper used by the date picker dialog.
Widget _buildDatePickerTheme(BuildContext context, Widget? child) {
final theme = Theme.of(context);

View file

@ -17,6 +17,7 @@ class _TodayScreenScaffold extends StatefulWidget {
required this.onPushToTomorrow,
required this.onPushToBacklog,
required this.onRemoveCard,
required this.onBreakUpCard,
required this.onAddTask,
required this.onClearSelection,
required this.onPreviousDate,
@ -56,6 +57,14 @@ class _TodayScreenScaffold extends StatefulWidget {
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function(TimelineCardModel card) onRemoveCard;
/// Stores the `onBreakUpCard` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
)
onBreakUpCard;
/// Stores the `onAddTask` value for this object.
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final Future<void> Function() onAddTask;

View file

@ -134,6 +134,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
onPushToTomorrow: widget.onPushToTomorrow,
onPushToBacklog: widget.onPushToBacklog,
onRemove: widget.onRemoveCard,
onBreakUp: widget.onBreakUpCard,
),
),
],

View file

@ -13,6 +13,7 @@ import 'package:scheduler_core/scheduler_core.dart'
import 'package:scheduler_persistence_sqlite/sqlite.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart';
import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart';
import 'runtime/focus_flow_file_logger.dart';
@ -204,6 +205,23 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
);
}
/// Creates a read controller for the backlog screen.
@override
UiReadController<BacklogQueryResult> createBacklogController() {
scheduler_core.logger.finer(() => 'Creating backlog read controller.');
return ApplicationReadController<BacklogQueryResult>(
read: () {
return managementUseCases.getBacklog(
GetBacklogRequest(
context: context('ui-read-backlog'),
sortKey: BacklogSortKey.age,
),
);
},
isEmpty: (state) => state.items.isEmpty,
);
}
/// Creates the command controller used by interactive scheduler controls.
@override
SchedulerCommandController createCommandController({

View file

@ -7,6 +7,7 @@ library;
import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart';
import '../controllers/selected_date_controller.dart';
import '../controllers/today_screen_controller.dart';
@ -32,6 +33,9 @@ abstract interface class SchedulerAppComposition {
required SelectedDateController selectedDates,
});
/// Creates a read controller for the backlog screen.
UiReadController<BacklogQueryResult> createBacklogController();
/// Creates the command controller used by interactive scheduler controls.
SchedulerCommandController createCommandController({
required ReadRefresh refreshReads,

View file

@ -304,6 +304,34 @@ class SchedulerCommandController extends ChangeNotifier {
);
}
/// Breaks a parent task into direct child tasks.
Future<void> breakUpTask({
required String parentTaskId,
required List<ApplicationChildTaskDraft> children,
DateTime? expectedUpdatedAt,
}) async {
final commandAt = now();
logger.debug(
() =>
'UI command requested: break up task. '
'parentTaskId=$parentTaskId childCount=${children.length} '
'hasExpectedUpdatedAt=${expectedUpdatedAt != null}',
);
await _run(
label: 'Breaking up task',
successMessage: 'Task broken up',
refreshAfterSuccess: true,
action: (operationId) {
return commands.breakUpTask(
context: contextFor(operationId, now: commandAt),
parentTaskId: parentTaskId,
children: children,
expectedUpdatedAt: expectedUpdatedAt,
);
},
);
}
/// Permanently removes a task from persistence.
Future<void> removeTask({
required String taskId,

View file

@ -8,10 +8,37 @@ import 'package:flutter/material.dart';
import '../theme/focus_flow_tokens.dart';
/// Static sidebar for primary FocusFlow navigation.
/// Primary screens the sidebar can switch the main content area to.
///
/// Only [today] and [backlog] are wired to real navigation. Projects and
/// Reports remain presentational-only nav items: they are outside the V1 MVP
/// boundary (AGENTS.md section 8 scopes V1 to Today + Backlog only).
enum SidebarScreen {
/// The compact Today timeline screen.
today,
/// The backlog screen.
backlog,
}
/// Sidebar for primary FocusFlow navigation.
class Sidebar extends StatelessWidget {
/// Creates the FocusFlow sidebar.
const Sidebar({super.key});
/// Creates the FocusFlow sidebar with the given [activeScreen] highlighted.
const Sidebar({
required this.activeScreen,
required this.onSelectToday,
required this.onSelectBacklog,
super.key,
});
/// Screen currently shown in the main content area.
final SidebarScreen activeScreen;
/// Invoked when the Today nav item is activated.
final VoidCallback onSelectToday;
/// Invoked when the Backlog nav item is activated.
final VoidCallback onSelectBacklog;
/// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
@ -29,11 +56,16 @@ class Sidebar extends StatelessWidget {
_NavItem(
label: 'Today',
icon: Icons.calendar_today,
active: true,
onTap: () {},
active: activeScreen == SidebarScreen.today,
onTap: onSelectToday,
),
const SizedBox(height: 16),
_NavItem(label: 'Backlog', icon: Icons.layers, onTap: () {}),
_NavItem(
label: 'Backlog',
icon: Icons.layers,
active: activeScreen == SidebarScreen.backlog,
onTap: onSelectBacklog,
),
const SizedBox(height: 16),
_NavItem(label: 'Projects', icon: Icons.work_outline, onTap: () {}),
const SizedBox(height: 16),
@ -127,7 +159,9 @@ class _NavItem extends StatelessWidget {
return Material(
color: Colors.transparent,
child: InkWell(
key: active ? const ValueKey('nav-today-active') : null,
key: active
? ValueKey('nav-${label.toLowerCase()}-active')
: ValueKey('nav-${label.toLowerCase()}'),
borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius),
onTap: onTap,
child: Container(

View file

@ -0,0 +1,250 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
part of '../task_selection_modal.dart';
/// Editable child-task row state owned by [_BreakUpDialog].
class _BreakUpRow {
/// Creates an empty break-up row with fresh text controllers.
_BreakUpRow()
: titleController = TextEditingController(),
durationController = TextEditingController();
/// Controls the row's child task title field.
final TextEditingController titleController;
/// Controls the row's optional duration-in-minutes field.
final TextEditingController durationController;
/// Optional priority selected for this row.
PriorityLevel? priority;
/// Reward level selected for this row.
RewardLevel reward = RewardLevel.notSet;
/// Releases the text controllers owned by this row.
void dispose() {
titleController.dispose();
durationController.dispose();
}
}
/// 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 {
/// Creates a break-up dialog.
const _BreakUpDialog();
/// 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();
}
/// 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> {
/// Editable rows currently shown in the dialog.
final List<_BreakUpRow> _rows = [_BreakUpRow()];
/// Releases resources owned by this object before it leaves the tree.
/// Controllers, listeners, and other disposable collaborators should be cleaned up here to avoid stale callbacks.
@override
void dispose() {
for (final row in _rows) {
row.dispose();
}
super.dispose();
}
/// Whether at least one row has a non-empty title.
bool get _canSubmit =>
_rows.any((row) => row.titleController.text.trim().isNotEmpty);
/// Appends a new empty row.
void _addRow() {
setState(() => _rows.add(_BreakUpRow()));
}
/// Removes the row at [index], keeping at least one row.
void _removeRow(int index) {
if (_rows.length <= 1) {
return;
}
setState(() {
_rows.removeAt(index).dispose();
});
}
/// Builds child task drafts from rows with a non-empty title and closes
/// the dialog with the resulting list.
void _submit() {
final drafts = <ApplicationChildTaskDraft>[
for (final row in _rows)
if (row.titleController.text.trim().isNotEmpty)
ApplicationChildTaskDraft(
title: row.titleController.text.trim(),
priority: row.priority,
reward: row.reward,
durationMinutes: int.tryParse(row.durationController.text.trim()),
),
];
Navigator.of(context).pop(drafts);
}
/// 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) {
return AlertDialog(
backgroundColor: FocusFlowTokens.elevatedPanel,
title: const Text(
'Break up task',
style: TextStyle(color: FocusFlowTokens.textPrimary),
),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < _rows.length; i++)
_BreakUpRowFields(
key: ValueKey('break-up-row-$i'),
rowIndex: i,
row: _rows[i],
onChanged: () => setState(() {}),
onRemove: _rows.length > 1 ? () => _removeRow(i) : null,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const ValueKey('break-up-add-row'),
onPressed: _addRow,
icon: const Icon(Icons.add),
label: const Text('Add another task'),
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
key: const ValueKey('break-up-confirm'),
onPressed: _canSubmit ? _submit : null,
child: const Text('Break up'),
),
],
);
}
}
/// Editable fields for a single [_BreakUpRow].
class _BreakUpRowFields extends StatelessWidget {
/// Creates fields bound to [row] at [rowIndex].
const _BreakUpRowFields({
required this.rowIndex,
required this.row,
required this.onChanged,
this.onRemove,
super.key,
});
/// Position of this row, used to build stable field keys.
final int rowIndex;
/// Row whose fields are rendered and mutated in place.
final _BreakUpRow row;
/// Invoked after any field in this row changes.
final VoidCallback onChanged;
/// Invoked when this row should be removed. Null when removal is disabled.
final VoidCallback? onRemove;
/// 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) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: TextField(
key: ValueKey('break-up-row-$rowIndex-title'),
controller: row.titleController,
decoration: const InputDecoration(labelText: 'Task title'),
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 8),
SizedBox(
width: 64,
child: TextField(
key: ValueKey('break-up-row-$rowIndex-duration'),
controller: row.durationController,
decoration: const InputDecoration(labelText: 'Min'),
keyboardType: TextInputType.number,
onChanged: (_) => onChanged(),
),
),
const SizedBox(width: 8),
SizedBox(
width: 132,
child: DropdownButtonFormField<PriorityLevel?>(
key: ValueKey('break-up-row-$rowIndex-priority'),
initialValue: row.priority,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Priority'),
items: [
const DropdownMenuItem(value: null, child: Text('Unset')),
for (final level in PriorityLevel.values)
DropdownMenuItem(value: level, child: Text(level.name)),
],
onChanged: (value) {
row.priority = value;
onChanged();
},
),
),
const SizedBox(width: 8),
SizedBox(
width: 132,
child: DropdownButtonFormField<RewardLevel>(
key: ValueKey('break-up-row-$rowIndex-reward'),
initialValue: row.reward,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Reward'),
items: [
for (final level in RewardLevel.values)
DropdownMenuItem(value: level, child: Text(level.name)),
],
onChanged: (value) {
if (value == null) {
return;
}
row.reward = value;
onChanged();
},
),
),
if (onRemove != null)
IconButton(
tooltip: 'Remove row',
onPressed: onRemove,
icon: const Icon(Icons.close),
),
],
),
);
}
}

View file

@ -7,6 +7,7 @@ library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:scheduler_core/scheduler_core.dart';
import '../models/today_screen_models.dart';
import '../theme/focus_flow_tokens.dart';
@ -15,6 +16,7 @@ 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';
@ -31,6 +33,7 @@ class TaskSelectionModal extends StatelessWidget {
this.onPushToTomorrow,
this.onPushToBacklog,
this.onRemove,
this.onBreakUp,
super.key,
});
@ -55,6 +58,26 @@ class TaskSelectionModal extends StatelessWidget {
/// Callback invoked when Remove is selected.
final Future<void> Function(TimelineCardModel card)? onRemove;
/// Callback invoked with drafted child tasks after Break up is confirmed.
final Future<void> Function(
TimelineCardModel card,
List<ApplicationChildTaskDraft> children,
)?
onBreakUp;
/// Opens the break-up dialog and forwards drafted child tasks to
/// [onBreakUp] 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;
}
await onBreakUp!(card, drafts);
}
/// 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
@ -132,8 +155,21 @@ class TaskSelectionModal extends StatelessWidget {
_ActionButton(
icon: Icons.inventory_2_outlined,
label: 'Backlog',
onTap: onPushToBacklog == null
? null
: () {
unawaited(onPushToBacklog!(card));
},
),
_ActionButton(
icon: Icons.apps,
label: 'Break up',
onTap: onBreakUp == null
? null
: () {
unawaited(_handleBreakUp(context));
},
),
const _ActionButton(icon: Icons.apps, label: 'Break up'),
_ActionButton(
icon: Icons.delete_outline,
label: 'Remove',

View file

@ -96,51 +96,64 @@ void main() {
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
});
testWidgets('task modal opens closes and keeps compact actions inert', (
tester,
) async {
await _pumpPlanApp(tester);
testWidgets(
'task modal opens and closes, and Break up opens a cancellable dialog',
(tester) async {
await _pumpPlanApp(tester);
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
await _scrollIntoView(tester, const ValueKey('pay-bill'));
await tester.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle();
await _scrollIntoView(tester, const ValueKey('pay-bill'));
await tester.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Pay bill'), findsNWidgets(2));
expect(find.textContaining('Required'), findsWidgets);
expect(find.byTooltip('Reward level 2'), findsOneWidget);
expect(find.byTooltip('Medium effort'), findsOneWidget);
expect(find.byTooltip('Mark complete'), findsNothing);
expect(find.text('Reward level 2'), findsNothing);
expect(find.text('Medium effort'), findsNothing);
expect(find.text('Done'), findsNothing);
expect(find.text('Push'), findsWidgets);
expect(find.text('Move to Backlog'), findsNothing);
expect(find.text('Backlog'), findsWidgets);
expect(find.text('Break up'), findsOneWidget);
expect(
find.byKey(const ValueKey('task-selection-modal')),
findsOneWidget,
);
expect(find.text('Pay bill'), findsNWidgets(2));
expect(find.textContaining('Required'), findsWidgets);
expect(find.byTooltip('Reward level 2'), findsOneWidget);
expect(find.byTooltip('Medium effort'), findsOneWidget);
expect(find.byTooltip('Mark complete'), findsNothing);
expect(find.text('Reward level 2'), findsNothing);
expect(find.text('Medium effort'), findsNothing);
expect(find.text('Done'), findsNothing);
expect(find.text('Push'), findsWidgets);
expect(find.text('Move to Backlog'), findsNothing);
expect(find.text('Backlog'), findsWidgets);
expect(find.text('Break up'), findsOneWidget);
await tester.tap(find.text('Break up'));
await tester.pumpAndSettle();
await tester.tap(find.text('Break up'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
expect(find.text('Pay bill'), findsNWidgets(2));
expect(find.text('Break up task'), findsOneWidget);
expect(find.byKey(const ValueKey('break-up-confirm')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('close-task-modal')));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(
find.byKey(const ValueKey('task-selection-modal')),
findsOneWidget,
);
expect(find.text('Pay bill'), findsNWidgets(2));
await _scrollIntoView(tester, const ValueKey('pay-bill'));
await tester.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(1450, 140));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('close-task-modal')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(find.text('Pay bill'), findsOneWidget);
});
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
await _scrollIntoView(tester, const ValueKey('pay-bill'));
await tester.tap(find.byKey(const ValueKey('pay-bill')));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(1450, 140));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
expect(find.text('Pay bill'), findsOneWidget);
},
);
testWidgets('task modal Push exposes destination labels and callbacks', (
tester,