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.
|
project when they are missing.
|
||||||
|
|
||||||
The compact Today screen can toggle completion for currently rendered task
|
The compact Today screen can toggle completion for currently rendered task
|
||||||
cards. Backlog widgets and quick-capture controls exist for tests and focused
|
cards. The sidebar switches between the Today and Backlog screens, and the
|
||||||
composition paths, but the sidebar Backlog navigation remains out of scope for
|
Backlog screen supports quick capture and scheduling backlog items.
|
||||||
this screen.
|
|
||||||
|
|
||||||
## Runtime Data
|
## Runtime Data
|
||||||
|
|
||||||
|
|
@ -102,13 +101,9 @@ boundary.
|
||||||
|
|
||||||
## Intentionally No-op
|
## Intentionally No-op
|
||||||
|
|
||||||
- Sidebar navigation.
|
|
||||||
- Date navigation and calendar buttons.
|
|
||||||
- Compact/Normal toggle.
|
- Compact/Normal toggle.
|
||||||
- Settings button.
|
- Settings button.
|
||||||
- Show upcoming.
|
|
||||||
- Timeline card action icons.
|
- Timeline card action icons.
|
||||||
- Modal Push, Move to Backlog, and Break up buttons.
|
|
||||||
|
|
||||||
## Persistence Proof
|
## Persistence Proof
|
||||||
|
|
||||||
|
|
@ -171,7 +166,10 @@ Run these from `apps/focus_flow_flutter/`.
|
||||||
|
|
||||||
## Next Plans
|
## Next Plans
|
||||||
|
|
||||||
- Wire visible Backlog navigation and quick-capture UI into the main shell.
|
- Give the Compact/Normal toggle real backing state (the live Today pipeline
|
||||||
- Wire functional Push, Move to Backlog, and Break up actions.
|
has no compact-mode concept yet; see Forgejo issue #11).
|
||||||
- Add real navigation/settings.
|
- 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.
|
- Add Shield/Recovery only in a later scope.
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a generic read controller for backlog panes.
|
/// Creates a generic read controller for backlog panes.
|
||||||
|
@override
|
||||||
UiReadController<BacklogQueryResult> createBacklogController() {
|
UiReadController<BacklogQueryResult> createBacklogController() {
|
||||||
return ApplicationReadController<BacklogQueryResult>(
|
return ApplicationReadController<BacklogQueryResult>(
|
||||||
read: () {
|
read: () {
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
import '../controllers/scheduler_command_controller.dart';
|
import '../controllers/scheduler_command_controller.dart';
|
||||||
|
import '../controllers/scheduler_read_controller.dart';
|
||||||
import '../controllers/selected_date_controller.dart';
|
import '../controllers/selected_date_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
import '../models/today_screen_models.dart';
|
import '../models/today_screen_models.dart';
|
||||||
import '../theme/focus_flow_theme.dart';
|
import '../theme/focus_flow_theme.dart';
|
||||||
import '../theme/focus_flow_tokens.dart';
|
import '../theme/focus_flow_tokens.dart';
|
||||||
import '../widgets/app_shell.dart';
|
import '../widgets/app_shell.dart';
|
||||||
|
import '../widgets/backlog_pane.dart';
|
||||||
import '../widgets/required_banner.dart';
|
import '../widgets/required_banner.dart';
|
||||||
import '../widgets/sidebar.dart';
|
import '../widgets/sidebar.dart';
|
||||||
import '../widgets/task_selection_modal.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.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
late final SchedulerCommandController commandController;
|
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.
|
/// 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.
|
/// Use this lifecycle point for controller setup, initial reads, and listener wiring that must happen once per mounted instance.
|
||||||
@override
|
@override
|
||||||
|
|
@ -28,12 +35,15 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
controller = widget.composition.createTodayScreenController(
|
controller = widget.composition.createTodayScreenController(
|
||||||
selectedDates: selectedDateController,
|
selectedDates: selectedDateController,
|
||||||
);
|
);
|
||||||
|
backlogController = widget.composition.createBacklogController();
|
||||||
commandController = widget.composition.createCommandController(
|
commandController = widget.composition.createCommandController(
|
||||||
refreshReads: controller.load,
|
refreshReads: _refreshReads,
|
||||||
selectedDate: () => selectedDateController.date,
|
selectedDate: () => selectedDateController.date,
|
||||||
);
|
);
|
||||||
logger.finer(() => 'FocusFlow home triggering initial Today load.');
|
logger.finer(() => 'FocusFlow home triggering initial Today load.');
|
||||||
controller.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.
|
/// 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.');
|
logger.debug(() => 'FocusFlow home disposing controllers.');
|
||||||
commandController.dispose();
|
commandController.dispose();
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
|
backlogController.dispose();
|
||||||
selectedDateController.dispose();
|
selectedDateController.dispose();
|
||||||
unawaited(widget.composition.dispose());
|
unawaited(widget.composition.dispose());
|
||||||
super.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.
|
/// 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.
|
/// 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 {
|
Future<void> _toggleCardCompletion(TimelineCardModel card) async {
|
||||||
|
|
@ -117,6 +154,23 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
controller.clearSelection();
|
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.
|
/// Moves the visible planning date backward by one day.
|
||||||
void _selectPreviousDate() {
|
void _selectPreviousDate() {
|
||||||
logger.finer(() => 'Previous date action requested.');
|
logger.finer(() => 'Previous date action requested.');
|
||||||
|
|
@ -156,58 +210,73 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: FocusFlowTokens.appBackground,
|
backgroundColor: FocusFlowTokens.appBackground,
|
||||||
body: AppShell(
|
body: AppShell(
|
||||||
sidebar: const Sidebar(),
|
sidebar: Sidebar(
|
||||||
child: AnimatedBuilder(
|
activeScreen: _activeScreen,
|
||||||
animation: controller,
|
onSelectToday: _showToday,
|
||||||
builder: (context, _) {
|
onSelectBacklog: _showBacklog,
|
||||||
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());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
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.
|
/// Builds the dark theme wrapper used by the date picker dialog.
|
||||||
Widget _buildDatePickerTheme(BuildContext context, Widget? child) {
|
Widget _buildDatePickerTheme(BuildContext context, Widget? child) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class _TodayScreenScaffold extends StatefulWidget {
|
||||||
required this.onPushToTomorrow,
|
required this.onPushToTomorrow,
|
||||||
required this.onPushToBacklog,
|
required this.onPushToBacklog,
|
||||||
required this.onRemoveCard,
|
required this.onRemoveCard,
|
||||||
|
required this.onBreakUpCard,
|
||||||
required this.onAddTask,
|
required this.onAddTask,
|
||||||
required this.onClearSelection,
|
required this.onClearSelection,
|
||||||
required this.onPreviousDate,
|
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.
|
/// 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;
|
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.
|
/// 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.
|
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||||
final Future<void> Function() onAddTask;
|
final Future<void> Function() onAddTask;
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
||||||
onPushToTomorrow: widget.onPushToTomorrow,
|
onPushToTomorrow: widget.onPushToTomorrow,
|
||||||
onPushToBacklog: widget.onPushToBacklog,
|
onPushToBacklog: widget.onPushToBacklog,
|
||||||
onRemove: widget.onRemoveCard,
|
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 'package:scheduler_persistence_sqlite/sqlite.dart';
|
||||||
|
|
||||||
import '../controllers/scheduler_command_controller.dart';
|
import '../controllers/scheduler_command_controller.dart';
|
||||||
|
import '../controllers/scheduler_read_controller.dart';
|
||||||
import '../controllers/selected_date_controller.dart';
|
import '../controllers/selected_date_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
import 'runtime/focus_flow_file_logger.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.
|
/// Creates the command controller used by interactive scheduler controls.
|
||||||
@override
|
@override
|
||||||
SchedulerCommandController createCommandController({
|
SchedulerCommandController createCommandController({
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ library;
|
||||||
import 'package:scheduler_core/scheduler_core.dart';
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
import '../controllers/scheduler_command_controller.dart';
|
import '../controllers/scheduler_command_controller.dart';
|
||||||
|
import '../controllers/scheduler_read_controller.dart';
|
||||||
import '../controllers/selected_date_controller.dart';
|
import '../controllers/selected_date_controller.dart';
|
||||||
import '../controllers/today_screen_controller.dart';
|
import '../controllers/today_screen_controller.dart';
|
||||||
|
|
||||||
|
|
@ -32,6 +33,9 @@ abstract interface class SchedulerAppComposition {
|
||||||
required SelectedDateController selectedDates,
|
required SelectedDateController selectedDates,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Creates a read controller for the backlog screen.
|
||||||
|
UiReadController<BacklogQueryResult> createBacklogController();
|
||||||
|
|
||||||
/// Creates the command controller used by interactive scheduler controls.
|
/// Creates the command controller used by interactive scheduler controls.
|
||||||
SchedulerCommandController createCommandController({
|
SchedulerCommandController createCommandController({
|
||||||
required ReadRefresh refreshReads,
|
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.
|
/// Permanently removes a task from persistence.
|
||||||
Future<void> removeTask({
|
Future<void> removeTask({
|
||||||
required String taskId,
|
required String taskId,
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,37 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/focus_flow_tokens.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 {
|
class Sidebar extends StatelessWidget {
|
||||||
/// Creates the FocusFlow sidebar.
|
/// Creates the FocusFlow sidebar with the given [activeScreen] highlighted.
|
||||||
const Sidebar({super.key});
|
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.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
|
|
@ -29,11 +56,16 @@ class Sidebar extends StatelessWidget {
|
||||||
_NavItem(
|
_NavItem(
|
||||||
label: 'Today',
|
label: 'Today',
|
||||||
icon: Icons.calendar_today,
|
icon: Icons.calendar_today,
|
||||||
active: true,
|
active: activeScreen == SidebarScreen.today,
|
||||||
onTap: () {},
|
onTap: onSelectToday,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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),
|
const SizedBox(height: 16),
|
||||||
_NavItem(label: 'Projects', icon: Icons.work_outline, onTap: () {}),
|
_NavItem(label: 'Projects', icon: Icons.work_outline, onTap: () {}),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -127,7 +159,9 @@ class _NavItem extends StatelessWidget {
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
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),
|
borderRadius: BorderRadius.circular(FocusFlowTokens.navRadius),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
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 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:scheduler_core/scheduler_core.dart';
|
||||||
|
|
||||||
import '../models/today_screen_models.dart';
|
import '../models/today_screen_models.dart';
|
||||||
import '../theme/focus_flow_tokens.dart';
|
import '../theme/focus_flow_tokens.dart';
|
||||||
|
|
@ -15,6 +16,7 @@ import 'timeline/difficulty_bars.dart';
|
||||||
import 'timeline/reward_icon.dart';
|
import 'timeline/reward_icon.dart';
|
||||||
|
|
||||||
part 'task_selection/action_button.dart';
|
part 'task_selection/action_button.dart';
|
||||||
|
part 'task_selection/break_up_dialog.dart';
|
||||||
part 'task_selection/header_metadata.dart';
|
part 'task_selection/header_metadata.dart';
|
||||||
part 'task_selection/info_tile.dart';
|
part 'task_selection/info_tile.dart';
|
||||||
part 'task_selection/modal_accent.dart';
|
part 'task_selection/modal_accent.dart';
|
||||||
|
|
@ -31,6 +33,7 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
this.onPushToTomorrow,
|
this.onPushToTomorrow,
|
||||||
this.onPushToBacklog,
|
this.onPushToBacklog,
|
||||||
this.onRemove,
|
this.onRemove,
|
||||||
|
this.onBreakUp,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -55,6 +58,26 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
/// Callback invoked when Remove is selected.
|
/// Callback invoked when Remove is selected.
|
||||||
final Future<void> Function(TimelineCardModel card)? onRemove;
|
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.
|
/// Builds the widget subtree for this component.
|
||||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||||
@override
|
@override
|
||||||
|
|
@ -132,8 +155,21 @@ class TaskSelectionModal extends StatelessWidget {
|
||||||
_ActionButton(
|
_ActionButton(
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
label: 'Backlog',
|
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(
|
_ActionButton(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.delete_outline,
|
||||||
label: 'Remove',
|
label: 'Remove',
|
||||||
|
|
|
||||||
|
|
@ -96,51 +96,64 @@ void main() {
|
||||||
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
expect(DifficultyBars.fillCountForLevel(DifficultyLevel.veryHard), 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('task modal opens closes and keeps compact actions inert', (
|
testWidgets(
|
||||||
tester,
|
'task modal opens and closes, and Break up opens a cancellable dialog',
|
||||||
) async {
|
(tester) async {
|
||||||
await _pumpPlanApp(tester);
|
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 _scrollIntoView(tester, const ValueKey('pay-bill'));
|
||||||
await tester.tap(find.byKey(const ValueKey('pay-bill')));
|
await tester.tap(find.byKey(const ValueKey('pay-bill')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
expect(
|
||||||
expect(find.text('Pay bill'), findsNWidgets(2));
|
find.byKey(const ValueKey('task-selection-modal')),
|
||||||
expect(find.textContaining('Required'), findsWidgets);
|
findsOneWidget,
|
||||||
expect(find.byTooltip('Reward level 2'), findsOneWidget);
|
);
|
||||||
expect(find.byTooltip('Medium effort'), findsOneWidget);
|
expect(find.text('Pay bill'), findsNWidgets(2));
|
||||||
expect(find.byTooltip('Mark complete'), findsNothing);
|
expect(find.textContaining('Required'), findsWidgets);
|
||||||
expect(find.text('Reward level 2'), findsNothing);
|
expect(find.byTooltip('Reward level 2'), findsOneWidget);
|
||||||
expect(find.text('Medium effort'), findsNothing);
|
expect(find.byTooltip('Medium effort'), findsOneWidget);
|
||||||
expect(find.text('Done'), findsNothing);
|
expect(find.byTooltip('Mark complete'), findsNothing);
|
||||||
expect(find.text('Push'), findsWidgets);
|
expect(find.text('Reward level 2'), findsNothing);
|
||||||
expect(find.text('Move to Backlog'), findsNothing);
|
expect(find.text('Medium effort'), findsNothing);
|
||||||
expect(find.text('Backlog'), findsWidgets);
|
expect(find.text('Done'), findsNothing);
|
||||||
expect(find.text('Break up'), findsOneWidget);
|
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.tap(find.text('Break up'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsOneWidget);
|
expect(find.text('Break up task'), findsOneWidget);
|
||||||
expect(find.text('Pay bill'), findsNWidgets(2));
|
expect(find.byKey(const ValueKey('break-up-confirm')), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const ValueKey('close-task-modal')));
|
await tester.tap(find.text('Cancel'));
|
||||||
await tester.pumpAndSettle();
|
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('close-task-modal')));
|
||||||
await tester.tap(find.byKey(const ValueKey('pay-bill')));
|
await tester.pumpAndSettle();
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await tester.tapAt(const Offset(1450, 140));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
expect(find.byKey(const ValueKey('task-selection-modal')), findsNothing);
|
||||||
expect(find.text('Pay bill'), findsOneWidget);
|
|
||||||
});
|
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', (
|
testWidgets('task modal Push exposes destination labels and callbacks', (
|
||||||
tester,
|
tester,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue