Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20
17 changed files with 606 additions and 10 deletions
|
|
@ -192,6 +192,19 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
|||
);
|
||||
}
|
||||
|
||||
/// Creates a generic read controller for the settings screen.
|
||||
@override
|
||||
UiReadController<OwnerSettings> createOwnerSettingsController() {
|
||||
return ApplicationReadController<OwnerSettings>(
|
||||
read: () {
|
||||
return managementUseCases.getOwnerSettings(
|
||||
GetOwnerSettingsRequest(context: context('ui-read-owner-settings')),
|
||||
);
|
||||
},
|
||||
isEmpty: (_) => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates the command controller used by interactive scheduler controls.
|
||||
@override
|
||||
SchedulerCommandController createCommandController({
|
||||
|
|
@ -200,6 +213,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
|
|||
}) {
|
||||
return SchedulerCommandController(
|
||||
commands: commandUseCases,
|
||||
management: managementUseCases,
|
||||
contextFor: context,
|
||||
selectedDate: selectedDate,
|
||||
refreshReads: refreshReads,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import '../theme/focus_flow_theme.dart';
|
|||
import '../theme/focus_flow_tokens.dart';
|
||||
import '../widgets/app_shell.dart';
|
||||
import '../widgets/backlog_pane.dart';
|
||||
import '../widgets/dialogs/settings_dialog.dart';
|
||||
import '../widgets/required_banner.dart';
|
||||
import '../widgets/sidebar.dart';
|
||||
import '../widgets/task_selection_modal.dart';
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ 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 UiReadController<BacklogQueryResult> backlogController;
|
||||
|
||||
/// Stores the `ownerSettingsController` 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<OwnerSettings> ownerSettingsController;
|
||||
|
||||
/// Screen currently shown in the main content area.
|
||||
var _activeScreen = SidebarScreen.today;
|
||||
|
||||
|
|
@ -36,6 +40,8 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
selectedDates: selectedDateController,
|
||||
);
|
||||
backlogController = widget.composition.createBacklogController();
|
||||
ownerSettingsController = widget.composition
|
||||
.createOwnerSettingsController();
|
||||
commandController = widget.composition.createCommandController(
|
||||
refreshReads: _refreshReads,
|
||||
selectedDate: () => selectedDateController.date,
|
||||
|
|
@ -44,6 +50,8 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
controller.load();
|
||||
logger.finer(() => 'FocusFlow home triggering initial Backlog load.');
|
||||
unawaited(backlogController.load());
|
||||
logger.finer(() => 'FocusFlow home triggering initial settings load.');
|
||||
unawaited(ownerSettingsController.load());
|
||||
}
|
||||
|
||||
/// Releases resources owned by this object before it leaves the tree.
|
||||
|
|
@ -54,6 +62,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
commandController.dispose();
|
||||
controller.dispose();
|
||||
backlogController.dispose();
|
||||
ownerSettingsController.dispose();
|
||||
selectedDateController.dispose();
|
||||
unawaited(widget.composition.dispose());
|
||||
super.dispose();
|
||||
|
|
@ -65,6 +74,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
Future<void> _refreshReads() async {
|
||||
await controller.load();
|
||||
await backlogController.load();
|
||||
await ownerSettingsController.load();
|
||||
}
|
||||
|
||||
/// Switches the main content area to the Today screen.
|
||||
|
|
@ -171,6 +181,33 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Opens the settings dialog seeded with the current owner settings and
|
||||
/// applies the confirmed patch, if any.
|
||||
Future<void> _openSettings() async {
|
||||
logger.finer(() => 'Settings action requested.');
|
||||
final state = ownerSettingsController.state;
|
||||
final current = switch (state) {
|
||||
SchedulerReadData(:final value) => value,
|
||||
SchedulerReadEmpty(:final value) => value,
|
||||
_ => null,
|
||||
};
|
||||
if (current == null) {
|
||||
logger.warn(
|
||||
() => 'Settings action ignored: owner settings not yet loaded.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final update = await showDialog<OwnerSettingsUpdate>(
|
||||
context: context,
|
||||
builder: (_) => SettingsDialog(initial: current),
|
||||
);
|
||||
if (update == null) {
|
||||
logger.finer(() => 'Settings dialog dismissed without changes.');
|
||||
return;
|
||||
}
|
||||
await commandController.updateOwnerSettings(update);
|
||||
}
|
||||
|
||||
/// Moves the visible planning date backward by one day.
|
||||
void _selectPreviousDate() {
|
||||
logger.finer(() => 'Previous date action requested.');
|
||||
|
|
@ -214,6 +251,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
activeScreen: _activeScreen,
|
||||
onSelectToday: _showToday,
|
||||
onSelectBacklog: _showBacklog,
|
||||
onOpenSettings: () {
|
||||
unawaited(_openSettings());
|
||||
},
|
||||
),
|
||||
child: switch (_activeScreen) {
|
||||
SidebarScreen.today => _buildTodayContent(context),
|
||||
|
|
@ -253,6 +293,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
onChooseDate: () {
|
||||
unawaited(_chooseDate());
|
||||
},
|
||||
onOpenSettings: () {
|
||||
unawaited(_openSettings());
|
||||
},
|
||||
),
|
||||
TodayScreenReady(:final data) => _TodayScreenScaffold(
|
||||
data: data,
|
||||
|
|
@ -271,6 +314,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
onChooseDate: () {
|
||||
unawaited(_chooseDate());
|
||||
},
|
||||
onOpenSettings: () {
|
||||
unawaited(_openSettings());
|
||||
},
|
||||
),
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class _TodayScreenScaffold extends StatefulWidget {
|
|||
required this.onPreviousDate,
|
||||
required this.onNextDate,
|
||||
required this.onChooseDate,
|
||||
required this.onOpenSettings,
|
||||
});
|
||||
|
||||
/// Stores the `data` value for this object.
|
||||
|
|
@ -85,6 +86,10 @@ class _TodayScreenScaffold extends StatefulWidget {
|
|||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final VoidCallback onChooseDate;
|
||||
|
||||
/// Stores the `onOpenSettings` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final VoidCallback onOpenSettings;
|
||||
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
|
|||
onNextDate: widget.onNextDate,
|
||||
onDatePressed: widget.onChooseDate,
|
||||
onDateLabelPressed: _scrollTimelineToCurrentTime,
|
||||
onOpenSettings: widget.onOpenSettings,
|
||||
),
|
||||
if (widget.data.requiredBanner == null)
|
||||
const SizedBox(height: 16)
|
||||
|
|
|
|||
|
|
@ -222,6 +222,22 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
);
|
||||
}
|
||||
|
||||
/// Creates a read controller for the settings screen.
|
||||
@override
|
||||
UiReadController<OwnerSettings> createOwnerSettingsController() {
|
||||
scheduler_core.logger.finer(
|
||||
() => 'Creating owner settings read controller.',
|
||||
);
|
||||
return ApplicationReadController<OwnerSettings>(
|
||||
read: () {
|
||||
return managementUseCases.getOwnerSettings(
|
||||
GetOwnerSettingsRequest(context: context('ui-read-owner-settings')),
|
||||
);
|
||||
},
|
||||
isEmpty: (_) => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates the command controller used by interactive scheduler controls.
|
||||
@override
|
||||
SchedulerCommandController createCommandController({
|
||||
|
|
@ -234,6 +250,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
|||
);
|
||||
return SchedulerCommandController(
|
||||
commands: commandUseCases,
|
||||
management: managementUseCases,
|
||||
contextFor: context,
|
||||
selectedDate: selectedDate,
|
||||
refreshReads: refreshReads,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ abstract interface class SchedulerAppComposition {
|
|||
/// Creates a read controller for the backlog screen.
|
||||
UiReadController<BacklogQueryResult> createBacklogController();
|
||||
|
||||
/// Creates a read controller for the settings screen.
|
||||
UiReadController<OwnerSettings> createOwnerSettingsController();
|
||||
|
||||
/// Creates the command controller used by interactive scheduler controls.
|
||||
SchedulerCommandController createCommandController({
|
||||
required ReadRefresh refreshReads,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
/// Creates a command controller from scheduler use cases and UI callbacks.
|
||||
SchedulerCommandController({
|
||||
required this.commands,
|
||||
required this.management,
|
||||
required this.contextFor,
|
||||
required this.selectedDate,
|
||||
required this.refreshReads,
|
||||
|
|
@ -21,6 +22,10 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
/// Scheduler write use cases invoked by this controller.
|
||||
final V1ApplicationCommandUseCases commands;
|
||||
|
||||
/// Non-scheduling management use cases (owner settings, projects, locked
|
||||
/// time) invoked by this controller.
|
||||
final V1ApplicationManagementUseCases management;
|
||||
|
||||
/// Factory for command-scoped application operation contexts.
|
||||
final OperationContextFactory contextFor;
|
||||
|
||||
|
|
@ -184,7 +189,7 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
TaskType.locked ||
|
||||
TaskType.surprise ||
|
||||
TaskType.freeSlot => Future.value(
|
||||
ApplicationResult.failure(
|
||||
ApplicationResult<ApplicationCommandResult>.failure(
|
||||
ApplicationFailure(
|
||||
code: ApplicationFailureCode.validation,
|
||||
entityId: taskId,
|
||||
|
|
@ -332,6 +337,31 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
);
|
||||
}
|
||||
|
||||
/// Applies a patch to the owner's persisted settings.
|
||||
Future<void> updateOwnerSettings(OwnerSettingsUpdate update) async {
|
||||
final commandAt = now();
|
||||
logger.debug(
|
||||
() =>
|
||||
'UI command requested: update owner settings. '
|
||||
'hasTimeZone=${update.timeZoneId != null} '
|
||||
'hasDayStart=${update.dayStart != null} '
|
||||
'hasDayEnd=${update.dayEnd != null} '
|
||||
'hasCompactMode=${update.compactModeEnabled != null} '
|
||||
'hasStaleness=${update.backlogStalenessSettings != null}',
|
||||
);
|
||||
await _run<OwnerSettingsUpdateResult>(
|
||||
label: 'Saving settings',
|
||||
successMessage: 'Settings saved',
|
||||
refreshAfterSuccess: true,
|
||||
action: (operationId) {
|
||||
return management.updateOwnerSettings(
|
||||
context: contextFor(operationId, now: commandAt),
|
||||
update: update,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Permanently removes a task from persistence.
|
||||
Future<void> removeTask({
|
||||
required String taskId,
|
||||
|
|
@ -360,14 +390,11 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
|
||||
/// Runs the `_run` 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> _run({
|
||||
Future<void> _run<R>({
|
||||
required String label,
|
||||
required String successMessage,
|
||||
required bool refreshAfterSuccess,
|
||||
required Future<ApplicationResult<ApplicationCommandResult>> Function(
|
||||
String operationId,
|
||||
)
|
||||
action,
|
||||
required Future<ApplicationResult<R>> Function(String operationId) action,
|
||||
}) async {
|
||||
final operationId = _nextOperationId();
|
||||
logger.debug(
|
||||
|
|
|
|||
317
apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart
Normal file
317
apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Renders the shared Settings dialog for the FocusFlow Flutter UI.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import '../../theme/focus_flow_tokens.dart';
|
||||
|
||||
/// Time zone codes recognized by the app's runtime config loader.
|
||||
const _timeZoneOptions = <String>[
|
||||
'UTC',
|
||||
'GMT',
|
||||
'PST',
|
||||
'PDT',
|
||||
'MST',
|
||||
'MDT',
|
||||
'CST',
|
||||
'CDT',
|
||||
'EST',
|
||||
'EDT',
|
||||
];
|
||||
|
||||
/// Dialog that surfaces persisted [OwnerSettings] fields for editing.
|
||||
///
|
||||
/// Returns an [OwnerSettingsUpdate] patch containing only the fields the user
|
||||
/// changed when confirmed, or `null` when cancelled.
|
||||
class SettingsDialog extends StatefulWidget {
|
||||
/// Creates a settings dialog seeded with the currently persisted [initial]
|
||||
/// settings.
|
||||
const SettingsDialog({required this.initial, super.key});
|
||||
|
||||
/// Currently persisted owner settings shown as the dialog's starting
|
||||
/// values.
|
||||
final OwnerSettings initial;
|
||||
|
||||
/// 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<SettingsDialog> createState() => _SettingsDialogState();
|
||||
}
|
||||
|
||||
/// Private implementation type for `_SettingsDialogState` in this library.
|
||||
class _SettingsDialogState extends State<SettingsDialog> {
|
||||
late String _timeZoneId;
|
||||
late WallTime _dayStart;
|
||||
late WallTime _dayEnd;
|
||||
late bool _compactModeEnabled;
|
||||
late final TextEditingController _freshDaysController;
|
||||
late final TextEditingController _agingDaysController;
|
||||
|
||||
/// Validation message for the day window, if any.
|
||||
String? _dayWindowError;
|
||||
|
||||
/// Validation message for the staleness thresholds, if any.
|
||||
String? _stalenessError;
|
||||
|
||||
/// 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
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initial;
|
||||
_timeZoneId = _timeZoneOptions.contains(initial.timeZoneId.toUpperCase())
|
||||
? initial.timeZoneId.toUpperCase()
|
||||
: _timeZoneOptions.first;
|
||||
_dayStart = initial.dayStart;
|
||||
_dayEnd = initial.dayEnd;
|
||||
_compactModeEnabled = initial.compactModeEnabled;
|
||||
_freshDaysController = TextEditingController(
|
||||
text: initial.backlogStalenessSettings.greenMaxAge.inDays.toString(),
|
||||
);
|
||||
_agingDaysController = TextEditingController(
|
||||
text: initial.backlogStalenessSettings.blueMaxAge.inDays.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
_freshDaysController.dispose();
|
||||
_agingDaysController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickDayStart() async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay(hour: _dayStart.hour, minute: _dayStart.minute),
|
||||
);
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_dayStart = WallTime(hour: picked.hour, minute: picked.minute);
|
||||
_dayWindowError = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDayEnd() async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay(hour: _dayEnd.hour, minute: _dayEnd.minute),
|
||||
);
|
||||
if (picked == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_dayEnd = WallTime(hour: picked.hour, minute: picked.minute);
|
||||
_dayWindowError = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (_dayStart.compareTo(_dayEnd) >= 0) {
|
||||
setState(() => _dayWindowError = 'Day end must be after day start.');
|
||||
return;
|
||||
}
|
||||
final freshDays = int.tryParse(_freshDaysController.text.trim());
|
||||
final agingDays = int.tryParse(_agingDaysController.text.trim());
|
||||
if (freshDays == null ||
|
||||
agingDays == null ||
|
||||
freshDays < 0 ||
|
||||
agingDays < freshDays) {
|
||||
setState(
|
||||
() => _stalenessError =
|
||||
'Aging must be a number of days at least as large as Fresh.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(
|
||||
OwnerSettingsUpdate(
|
||||
timeZoneId: _timeZoneId,
|
||||
dayStart: _dayStart,
|
||||
dayEnd: _dayEnd,
|
||||
compactModeEnabled: _compactModeEnabled,
|
||||
backlogStalenessSettings: BacklogStalenessSettings(
|
||||
greenMaxAge: Duration(days: freshDays),
|
||||
blueMaxAge: Duration(days: agingDays),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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(
|
||||
'Settings',
|
||||
style: TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Time & day',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
key: const ValueKey('settings-timezone'),
|
||||
initialValue: _timeZoneId,
|
||||
decoration: const InputDecoration(labelText: 'Time zone'),
|
||||
items: [
|
||||
for (final zone in _timeZoneOptions)
|
||||
DropdownMenuItem(value: zone, child: Text(zone)),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
setState(() => _timeZoneId = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
key: const ValueKey('settings-day-start'),
|
||||
onPressed: _pickDayStart,
|
||||
child: Text('Day start: ${_dayStart.formatted}'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
key: const ValueKey('settings-day-end'),
|
||||
onPressed: _pickDayEnd,
|
||||
child: Text('Day end: ${_dayEnd.formatted}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_dayWindowError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_dayWindowError!,
|
||||
style: const TextStyle(color: FocusFlowTokens.accentMagenta),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Compact mode',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
key: const ValueKey('settings-compact-mode'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text(
|
||||
'Compact Today view',
|
||||
style: TextStyle(color: FocusFlowTokens.textPrimary),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Saved now; visual toggle is coming soon.',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
value: _compactModeEnabled,
|
||||
onChanged: (value) {
|
||||
setState(() => _compactModeEnabled = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Backlog freshness',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const ValueKey('settings-fresh-days'),
|
||||
controller: _freshDaysController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Fresh (days)',
|
||||
),
|
||||
onChanged: (_) => setState(() => _stalenessError = null),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const ValueKey('settings-aging-days'),
|
||||
controller: _agingDaysController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Aging (days)',
|
||||
),
|
||||
onChanged: (_) => setState(() => _stalenessError = null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'Anything older than Aging is shown as Stale.',
|
||||
style: TextStyle(
|
||||
color: FocusFlowTokens.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (_stalenessError != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_stalenessError!,
|
||||
style: const TextStyle(color: FocusFlowTokens.accentMagenta),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
key: const ValueKey('settings-save'),
|
||||
onPressed: _submit,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a [WallTime] as `HH:MM`.
|
||||
extension on WallTime {
|
||||
String get formatted =>
|
||||
'${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ class Sidebar extends StatelessWidget {
|
|||
required this.activeScreen,
|
||||
required this.onSelectToday,
|
||||
required this.onSelectBacklog,
|
||||
required this.onOpenSettings,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -40,6 +41,9 @@ class Sidebar extends StatelessWidget {
|
|||
/// Invoked when the Backlog nav item is activated.
|
||||
final VoidCallback onSelectBacklog;
|
||||
|
||||
/// Invoked when the Settings nav item is activated.
|
||||
final VoidCallback onOpenSettings;
|
||||
|
||||
/// 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
|
||||
|
|
@ -73,7 +77,11 @@ class Sidebar extends StatelessWidget {
|
|||
const SizedBox(height: 30),
|
||||
const Divider(color: FocusFlowTokens.subtleBorder),
|
||||
const SizedBox(height: 18),
|
||||
_NavItem(label: 'Settings', icon: Icons.settings, onTap: () {}),
|
||||
_NavItem(
|
||||
label: 'Settings',
|
||||
icon: Icons.settings,
|
||||
onTap: onOpenSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class TopBar extends StatelessWidget {
|
|||
this.onNextDate,
|
||||
this.onDatePressed,
|
||||
this.onDateLabelPressed,
|
||||
this.onOpenSettings,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -40,6 +41,9 @@ class TopBar extends StatelessWidget {
|
|||
/// Callback invoked when the displayed date label is activated.
|
||||
final VoidCallback? onDateLabelPressed;
|
||||
|
||||
/// Callback invoked when the Settings button is activated.
|
||||
final VoidCallback? onOpenSettings;
|
||||
|
||||
/// 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
|
||||
|
|
@ -130,7 +134,7 @@ class TopBar extends StatelessWidget {
|
|||
const Spacer(),
|
||||
_ModeToggle(metrics: metrics),
|
||||
SizedBox(width: metrics.settingsGap),
|
||||
_SettingsButton(metrics: metrics),
|
||||
_SettingsButton(metrics: metrics, onPressed: onOpenSettings),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,18 +8,22 @@ part of '../top_bar.dart';
|
|||
class _SettingsButton extends StatelessWidget {
|
||||
/// Creates a `_SettingsButton` instance with the values required by its invariants.
|
||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||
const _SettingsButton({required this.metrics});
|
||||
const _SettingsButton({required this.metrics, this.onPressed});
|
||||
|
||||
/// Stores the `metrics` value for this object.
|
||||
/// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
|
||||
final _TopBarMetrics metrics;
|
||||
|
||||
/// Callback invoked when the button is activated.
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// 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 OutlinedButton(
|
||||
onPressed: () {},
|
||||
key: const ValueKey('top-bar-settings-button'),
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: FocusFlowTokens.textPrimary,
|
||||
side: BorderSide(
|
||||
|
|
|
|||
115
apps/focus_flow_flutter/test/app/settings_test.dart
Normal file
115
apps/focus_flow_flutter/test/app/settings_test.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests app-level settings dialog behavior.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import 'package:focus_flow_flutter/app/demo_scheduler_composition.dart';
|
||||
import 'package:focus_flow_flutter/app/focus_flow_app.dart';
|
||||
|
||||
/// Runs app-level settings dialog tests.
|
||||
void main() {
|
||||
testWidgets('sidebar Settings opens a dialog seeded with persisted values', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('nav-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(find.text('UTC'), findsOneWidget);
|
||||
|
||||
final compactSwitch = tester.widget<SwitchListTile>(
|
||||
find.byKey(const ValueKey('settings-compact-mode')),
|
||||
);
|
||||
expect(compactSwitch.value, isTrue);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('top bar Settings button opens the same dialog', (tester) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('top-bar-settings-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('settings-timezone')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('saving settings persists the change and reopens with it', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('nav-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('settings-timezone')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('EST').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('settings-save')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('nav-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('EST'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('invalid staleness thresholds show a calm inline error', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpPlanApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('nav-settings')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('settings-aging-days')),
|
||||
'1',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('settings-save')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsOneWidget);
|
||||
expect(
|
||||
find.text('Aging must be a number of days at least as large as Fresh.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
}
|
||||
|
||||
/// Pumps the FocusFlow app at the desktop test size.
|
||||
Future<void> _pumpPlanApp(WidgetTester tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1586, 992));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(FocusFlowApp(composition: _composition()));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Builds the seeded demo composition used by settings dialog tests.
|
||||
DemoSchedulerComposition _composition() {
|
||||
return DemoSchedulerComposition.seeded(selectedDate: CivilDate(2025, 5, 20));
|
||||
}
|
||||
|
|
@ -552,6 +552,7 @@ SchedulerCommandController _commandController(
|
|||
}) {
|
||||
return SchedulerCommandController(
|
||||
commands: composition.commandUseCases,
|
||||
management: composition.managementUseCases,
|
||||
contextFor: composition.context,
|
||||
selectedDate: () => selectedDate,
|
||||
refreshReads: () async {},
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ part 'application_management/drafts/project_profile_draft.dart';
|
|||
part 'application_management/updates/project_profile_update.dart';
|
||||
part 'application_management/drafts/locked_block_draft.dart';
|
||||
part 'application_management/updates/locked_block_update.dart';
|
||||
part 'application_management/requests/get_owner_settings_request.dart';
|
||||
part 'application_management/results/owner_settings_update_result.dart';
|
||||
part 'application_management/updates/owner_settings_update.dart';
|
||||
part 'application_management/results/notice_acknowledgement_result.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../application_management.dart';
|
||||
|
||||
/// Owner settings query request.
|
||||
class GetOwnerSettingsRequest {
|
||||
/// Creates a `GetOwnerSettingsRequest` instance with the values required by its invariants.
|
||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||
const GetOwnerSettingsRequest({required this.context});
|
||||
|
||||
/// Read context containing owner scope and read instant.
|
||||
final ApplicationOperationContext context;
|
||||
}
|
||||
|
|
@ -116,6 +116,24 @@ class V1ApplicationManagementUseCases {
|
|||
);
|
||||
}
|
||||
|
||||
/// Load the effective owner settings, falling back to defaults when none
|
||||
/// have been persisted yet.
|
||||
Future<ApplicationResult<OwnerSettings>> getOwnerSettings(
|
||||
GetOwnerSettingsRequest request,
|
||||
) {
|
||||
return applicationStore.read(
|
||||
action: (repositories) async {
|
||||
final ownerId = request.context.ownerTimeZone.ownerId;
|
||||
logger.debug(() => 'Loading owner settings query. ownerId=$ownerId');
|
||||
final settings = await _ownerSettingsOrDefault(
|
||||
repositories,
|
||||
request.context.ownerTimeZone,
|
||||
);
|
||||
return ApplicationResult.success(settings);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs the `createProject` 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<ApplicationResult<ProjectProfile>> createProject({
|
||||
|
|
|
|||
Loading…
Reference in a new issue