Compare commits
1 commit
main
...
feat/9-12-
| Author | SHA1 | Date | |
|---|---|---|---|
| c357b7d97d |
13 changed files with 565 additions and 102 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
|||
}
|
||||
|
||||
/// Creates a generic read controller for backlog panes.
|
||||
@override
|
||||
UiReadController<BacklogQueryResult> createBacklogController() {
|
||||
return ApplicationReadController<BacklogQueryResult>(
|
||||
read: () {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,17 +210,32 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
return Scaffold(
|
||||
backgroundColor: FocusFlowTokens.appBackground,
|
||||
body: AppShell(
|
||||
sidebar: const Sidebar(),
|
||||
child: AnimatedBuilder(
|
||||
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,
|
||||
),
|
||||
TodayScreenFailure(:final message) => _PlanIssue(message: message),
|
||||
TodayScreenEmpty(:final data) => _TodayScreenScaffold(
|
||||
data: data,
|
||||
selectedCard: controller.selectedCard,
|
||||
|
|
@ -176,6 +245,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
onPushToTomorrow: _pushCardToTomorrow,
|
||||
onPushToBacklog: _pushCardToBacklog,
|
||||
onRemoveCard: _removeCard,
|
||||
onBreakUpCard: _breakUpCard,
|
||||
onAddTask: _addGenericFlexibleTask,
|
||||
onClearSelection: controller.clearSelection,
|
||||
onPreviousDate: _selectPreviousDate,
|
||||
|
|
@ -193,6 +263,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
onPushToTomorrow: _pushCardToTomorrow,
|
||||
onPushToBacklog: _pushCardToBacklog,
|
||||
onRemoveCard: _removeCard,
|
||||
onBreakUpCard: _breakUpCard,
|
||||
onAddTask: _addGenericFlexibleTask,
|
||||
onClearSelection: controller.clearSelection,
|
||||
onPreviousDate: _selectPreviousDate,
|
||||
|
|
@ -203,8 +274,6 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
onPushToTomorrow: widget.onPushToTomorrow,
|
||||
onPushToBacklog: widget.onPushToBacklog,
|
||||
onRemove: widget.onRemoveCard,
|
||||
onBreakUp: widget.onBreakUpCard,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -96,9 +96,9 @@ void main() {
|
|||
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
||||
});
|
||||
|
||||
testWidgets('task modal opens closes and keeps compact actions inert', (
|
||||
tester,
|
||||
) async {
|
||||
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);
|
||||
|
|
@ -107,7 +107,10 @@ void main() {
|
|||
await tester.tap(find.byKey(const ValueKey('pay-bill')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), 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);
|
||||
|
|
@ -124,7 +127,16 @@ void main() {
|
|||
await tester.tap(find.text('Break up'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
||||
expect(find.text('Break up task'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('break-up-confirm')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('task-selection-modal')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Pay bill'), findsNWidgets(2));
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('close-task-modal')));
|
||||
|
|
@ -140,7 +152,8 @@ void main() {
|
|||
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue