From c357b7d97d7fa285f121c456d1de2f750a7b73ef Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 6 Jul 2026 01:46:59 -0700 Subject: [PATCH] feat(ui): wire sidebar Backlog nav and modal Move-to-Backlog/Break-up actions 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: https://git.opensourcesolarpunk.com/Circuit-Forge/focus-flow/issues/9 Closes: https://git.opensourcesolarpunk.com/Circuit-Forge/focus-flow/issues/12 Closes: https://git.opensourcesolarpunk.com/Circuit-Forge/focus-flow/issues/13 --- apps/focus_flow_flutter/README.md | 18 +- .../lib/app/demo_scheduler_composition.dart | 1 + .../lib/app/focus_flow_app.dart | 2 + .../lib/app/home/focus_flow_home_state.dart | 165 ++++++++---- .../lib/app/home/today_screen_scaffold.dart | 9 + .../app/home/today_screen_scaffold_state.dart | 1 + .../app/persistent_scheduler_composition.dart | 18 ++ .../lib/app/scheduler_app_composition.dart | 4 + .../scheduler_command_controller_impl.dart | 28 ++ .../lib/widgets/sidebar.dart | 48 +++- .../task_selection/break_up_dialog.dart | 250 ++++++++++++++++++ .../lib/widgets/task_selection_modal.dart | 38 ++- apps/focus_flow_flutter/test/widget_test.dart | 85 +++--- 13 files changed, 565 insertions(+), 102 deletions(-) create mode 100644 apps/focus_flow_flutter/lib/widgets/task_selection/break_up_dialog.dart diff --git a/apps/focus_flow_flutter/README.md b/apps/focus_flow_flutter/README.md index 427c026..5b59146 100644 --- a/apps/focus_flow_flutter/README.md +++ b/apps/focus_flow_flutter/README.md @@ -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. 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 60a5a51..55ba36a 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -177,6 +177,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition { } /// Creates a generic read controller for backlog panes. + @override UiReadController createBacklogController() { return ApplicationReadController( read: () { 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 979169f..3fa8a6c 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -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'; diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart index 15c23d8..7385d21 100644 --- a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -18,6 +18,13 @@ class _FocusFlowHomeState extends State { /// 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 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 { 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 { 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 _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 _toggleCardCompletion(TimelineCardModel card) async { @@ -117,6 +154,23 @@ class _FocusFlowHomeState extends State { controller.clearSelection(); } + /// Breaks [card] up into the drafted [children] tasks. + Future _breakUpCard( + TimelineCardModel card, + List 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 { 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); diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart index a435cfd..b0038b0 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart @@ -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 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 Function( + TimelineCardModel card, + List 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 Function() onAddTask; 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 86d5fb3..75100bc 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 @@ -134,6 +134,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> { onPushToTomorrow: widget.onPushToTomorrow, onPushToBacklog: widget.onPushToBacklog, onRemove: widget.onRemoveCard, + onBreakUp: widget.onBreakUpCard, ), ), ], diff --git a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart index 1133e1e..9717e29 100644 --- a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart @@ -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 createBacklogController() { + scheduler_core.logger.finer(() => 'Creating backlog read controller.'); + return ApplicationReadController( + 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({ diff --git a/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart b/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart index 2a30e39..db31660 100644 --- a/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart +++ b/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart @@ -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 createBacklogController(); + /// Creates the command controller used by interactive scheduler controls. SchedulerCommandController createCommandController({ required ReadRefresh refreshReads, diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart index 5cd86fd..5b7eaa7 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -304,6 +304,34 @@ class SchedulerCommandController extends ChangeNotifier { ); } + /// Breaks a parent task into direct child tasks. + Future breakUpTask({ + required String parentTaskId, + required List 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 removeTask({ required String taskId, diff --git a/apps/focus_flow_flutter/lib/widgets/sidebar.dart b/apps/focus_flow_flutter/lib/widgets/sidebar.dart index ab7a204..792119a 100644 --- a/apps/focus_flow_flutter/lib/widgets/sidebar.dart +++ b/apps/focus_flow_flutter/lib/widgets/sidebar.dart @@ -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( diff --git a/apps/focus_flow_flutter/lib/widgets/task_selection/break_up_dialog.dart b/apps/focus_flow_flutter/lib/widgets/task_selection/break_up_dialog.dart new file mode 100644 index 0000000..97f4cad --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/task_selection/break_up_dialog.dart @@ -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 = [ + 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( + 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( + 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), + ), + ], + ), + ); + } +} 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 18c29fc..28a04d2 100644 --- a/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart +++ b/apps/focus_flow_flutter/lib/widgets/task_selection_modal.dart @@ -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 Function(TimelineCardModel card)? onRemove; + /// Callback invoked with drafted child tasks after Break up is confirmed. + final Future Function( + TimelineCardModel card, + List children, + )? + onBreakUp; + + /// Opens the break-up dialog and forwards drafted child tasks to + /// [onBreakUp] 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; + } + 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', diff --git a/apps/focus_flow_flutter/test/widget_test.dart b/apps/focus_flow_flutter/test/widget_test.dart index 3f53e5d..6d50617 100644 --- a/apps/focus_flow_flutter/test/widget_test.dart +++ b/apps/focus_flow_flutter/test/widget_test.dart @@ -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, -- 2.45.2