Wire Backlog nav, Break up, Settings dialog + merge Backlog Board feature #20

Open
pyr0ball wants to merge 26 commits from Circuit-Forge/focus-flow:feat/9-12-13-wire-backlog-nav-and-modal-actions into main
17 changed files with 606 additions and 10 deletions
Showing only changes of commit 6f0da3c8aa - Show all commits

View file

@ -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. /// Creates the command controller used by interactive scheduler controls.
@override @override
SchedulerCommandController createCommandController({ SchedulerCommandController createCommandController({
@ -200,6 +213,7 @@ class DemoSchedulerComposition implements SchedulerAppComposition {
}) { }) {
return SchedulerCommandController( return SchedulerCommandController(
commands: commandUseCases, commands: commandUseCases,
management: managementUseCases,
contextFor: context, contextFor: context,
selectedDate: selectedDate, selectedDate: selectedDate,
refreshReads: refreshReads, refreshReads: refreshReads,

View file

@ -18,6 +18,7 @@ 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/backlog_pane.dart';
import '../widgets/dialogs/settings_dialog.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';

View file

@ -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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
late final UiReadController<BacklogQueryResult> backlogController; 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. /// Screen currently shown in the main content area.
var _activeScreen = SidebarScreen.today; var _activeScreen = SidebarScreen.today;
@ -36,6 +40,8 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
selectedDates: selectedDateController, selectedDates: selectedDateController,
); );
backlogController = widget.composition.createBacklogController(); backlogController = widget.composition.createBacklogController();
ownerSettingsController = widget.composition
.createOwnerSettingsController();
commandController = widget.composition.createCommandController( commandController = widget.composition.createCommandController(
refreshReads: _refreshReads, refreshReads: _refreshReads,
selectedDate: () => selectedDateController.date, selectedDate: () => selectedDateController.date,
@ -44,6 +50,8 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
controller.load(); controller.load();
logger.finer(() => 'FocusFlow home triggering initial Backlog load.'); logger.finer(() => 'FocusFlow home triggering initial Backlog load.');
unawaited(backlogController.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. /// Releases resources owned by this object before it leaves the tree.
@ -54,6 +62,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
commandController.dispose(); commandController.dispose();
controller.dispose(); controller.dispose();
backlogController.dispose(); backlogController.dispose();
ownerSettingsController.dispose();
selectedDateController.dispose(); selectedDateController.dispose();
unawaited(widget.composition.dispose()); unawaited(widget.composition.dispose());
super.dispose(); super.dispose();
@ -65,6 +74,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
Future<void> _refreshReads() async { Future<void> _refreshReads() async {
await controller.load(); await controller.load();
await backlogController.load(); await backlogController.load();
await ownerSettingsController.load();
} }
/// Switches the main content area to the Today screen. /// 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. /// 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.');
@ -214,6 +251,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
activeScreen: _activeScreen, activeScreen: _activeScreen,
onSelectToday: _showToday, onSelectToday: _showToday,
onSelectBacklog: _showBacklog, onSelectBacklog: _showBacklog,
onOpenSettings: () {
unawaited(_openSettings());
},
), ),
child: switch (_activeScreen) { child: switch (_activeScreen) {
SidebarScreen.today => _buildTodayContent(context), SidebarScreen.today => _buildTodayContent(context),
@ -253,6 +293,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
onChooseDate: () { onChooseDate: () {
unawaited(_chooseDate()); unawaited(_chooseDate());
}, },
onOpenSettings: () {
unawaited(_openSettings());
},
), ),
TodayScreenReady(:final data) => _TodayScreenScaffold( TodayScreenReady(:final data) => _TodayScreenScaffold(
data: data, data: data,
@ -271,6 +314,9 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
onChooseDate: () { onChooseDate: () {
unawaited(_chooseDate()); unawaited(_chooseDate());
}, },
onOpenSettings: () {
unawaited(_openSettings());
},
), ),
}; };
}, },

View file

@ -23,6 +23,7 @@ class _TodayScreenScaffold extends StatefulWidget {
required this.onPreviousDate, required this.onPreviousDate,
required this.onNextDate, required this.onNextDate,
required this.onChooseDate, required this.onChooseDate,
required this.onOpenSettings,
}); });
/// Stores the `data` value for this object. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final VoidCallback onChooseDate; 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. /// 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. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
@override @override

View file

@ -79,6 +79,7 @@ class _TodayScreenScaffoldState extends State<_TodayScreenScaffold> {
onNextDate: widget.onNextDate, onNextDate: widget.onNextDate,
onDatePressed: widget.onChooseDate, onDatePressed: widget.onChooseDate,
onDateLabelPressed: _scrollTimelineToCurrentTime, onDateLabelPressed: _scrollTimelineToCurrentTime,
onOpenSettings: widget.onOpenSettings,
), ),
if (widget.data.requiredBanner == null) if (widget.data.requiredBanner == null)
const SizedBox(height: 16) const SizedBox(height: 16)

View file

@ -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. /// Creates the command controller used by interactive scheduler controls.
@override @override
SchedulerCommandController createCommandController({ SchedulerCommandController createCommandController({
@ -234,6 +250,7 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition {
); );
return SchedulerCommandController( return SchedulerCommandController(
commands: commandUseCases, commands: commandUseCases,
management: managementUseCases,
contextFor: context, contextFor: context,
selectedDate: selectedDate, selectedDate: selectedDate,
refreshReads: refreshReads, refreshReads: refreshReads,

View file

@ -36,6 +36,9 @@ abstract interface class SchedulerAppComposition {
/// Creates a read controller for the backlog screen. /// Creates a read controller for the backlog screen.
UiReadController<BacklogQueryResult> createBacklogController(); UiReadController<BacklogQueryResult> createBacklogController();
/// Creates a read controller for the settings screen.
UiReadController<OwnerSettings> createOwnerSettingsController();
/// 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,

View file

@ -8,6 +8,7 @@ class SchedulerCommandController extends ChangeNotifier {
/// Creates a command controller from scheduler use cases and UI callbacks. /// Creates a command controller from scheduler use cases and UI callbacks.
SchedulerCommandController({ SchedulerCommandController({
required this.commands, required this.commands,
required this.management,
required this.contextFor, required this.contextFor,
required this.selectedDate, required this.selectedDate,
required this.refreshReads, required this.refreshReads,
@ -21,6 +22,10 @@ class SchedulerCommandController extends ChangeNotifier {
/// Scheduler write use cases invoked by this controller. /// Scheduler write use cases invoked by this controller.
final V1ApplicationCommandUseCases commands; 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. /// Factory for command-scoped application operation contexts.
final OperationContextFactory contextFor; final OperationContextFactory contextFor;
@ -184,7 +189,7 @@ class SchedulerCommandController extends ChangeNotifier {
TaskType.locked || TaskType.locked ||
TaskType.surprise || TaskType.surprise ||
TaskType.freeSlot => Future.value( TaskType.freeSlot => Future.value(
ApplicationResult.failure( ApplicationResult<ApplicationCommandResult>.failure(
ApplicationFailure( ApplicationFailure(
code: ApplicationFailureCode.validation, code: ApplicationFailureCode.validation,
entityId: taskId, 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. /// Permanently removes a task from persistence.
Future<void> removeTask({ Future<void> removeTask({
required String taskId, required String taskId,
@ -360,14 +390,11 @@ class SchedulerCommandController extends ChangeNotifier {
/// Runs the `_run` operation and completes after its asynchronous work finishes. /// 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. /// 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 label,
required String successMessage, required String successMessage,
required bool refreshAfterSuccess, required bool refreshAfterSuccess,
required Future<ApplicationResult<ApplicationCommandResult>> Function( required Future<ApplicationResult<R>> Function(String operationId) action,
String operationId,
)
action,
}) async { }) async {
final operationId = _nextOperationId(); final operationId = _nextOperationId();
logger.debug( logger.debug(

View 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')}';
}

View file

@ -28,6 +28,7 @@ class Sidebar extends StatelessWidget {
required this.activeScreen, required this.activeScreen,
required this.onSelectToday, required this.onSelectToday,
required this.onSelectBacklog, required this.onSelectBacklog,
required this.onOpenSettings,
super.key, super.key,
}); });
@ -40,6 +41,9 @@ class Sidebar extends StatelessWidget {
/// Invoked when the Backlog nav item is activated. /// Invoked when the Backlog nav item is activated.
final VoidCallback onSelectBacklog; final VoidCallback onSelectBacklog;
/// Invoked when the Settings nav item is activated.
final VoidCallback onOpenSettings;
/// 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
@ -73,7 +77,11 @@ class Sidebar extends StatelessWidget {
const SizedBox(height: 30), const SizedBox(height: 30),
const Divider(color: FocusFlowTokens.subtleBorder), const Divider(color: FocusFlowTokens.subtleBorder),
const SizedBox(height: 18), const SizedBox(height: 18),
_NavItem(label: 'Settings', icon: Icons.settings, onTap: () {}), _NavItem(
label: 'Settings',
icon: Icons.settings,
onTap: onOpenSettings,
),
], ],
), ),
), ),

View file

@ -22,6 +22,7 @@ class TopBar extends StatelessWidget {
this.onNextDate, this.onNextDate,
this.onDatePressed, this.onDatePressed,
this.onDateLabelPressed, this.onDateLabelPressed,
this.onOpenSettings,
super.key, super.key,
}); });
@ -40,6 +41,9 @@ class TopBar extends StatelessWidget {
/// Callback invoked when the displayed date label is activated. /// Callback invoked when the displayed date label is activated.
final VoidCallback? onDateLabelPressed; final VoidCallback? onDateLabelPressed;
/// Callback invoked when the Settings button is activated.
final VoidCallback? onOpenSettings;
/// 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
@ -130,7 +134,7 @@ class TopBar extends StatelessWidget {
const Spacer(), const Spacer(),
_ModeToggle(metrics: metrics), _ModeToggle(metrics: metrics),
SizedBox(width: metrics.settingsGap), SizedBox(width: metrics.settingsGap),
_SettingsButton(metrics: metrics), _SettingsButton(metrics: metrics, onPressed: onOpenSettings),
], ],
), ),
); );

View file

@ -8,18 +8,22 @@ part of '../top_bar.dart';
class _SettingsButton extends StatelessWidget { class _SettingsButton extends StatelessWidget {
/// Creates a `_SettingsButton` instance with the values required by its invariants. /// 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. /// 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. /// 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. /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior.
final _TopBarMetrics metrics; final _TopBarMetrics metrics;
/// Callback invoked when the button is activated.
final VoidCallback? onPressed;
/// 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
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OutlinedButton( return OutlinedButton(
onPressed: () {}, key: const ValueKey('top-bar-settings-button'),
onPressed: onPressed,
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: FocusFlowTokens.textPrimary, foregroundColor: FocusFlowTokens.textPrimary,
side: BorderSide( side: BorderSide(

View 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));
}

View file

@ -552,6 +552,7 @@ SchedulerCommandController _commandController(
}) { }) {
return SchedulerCommandController( return SchedulerCommandController(
commands: composition.commandUseCases, commands: composition.commandUseCases,
management: composition.managementUseCases,
contextFor: composition.context, contextFor: composition.context,
selectedDate: () => selectedDate, selectedDate: () => selectedDate,
refreshReads: () async {}, refreshReads: () async {},

View file

@ -29,6 +29,7 @@ part 'application_management/drafts/project_profile_draft.dart';
part 'application_management/updates/project_profile_update.dart'; part 'application_management/updates/project_profile_update.dart';
part 'application_management/drafts/locked_block_draft.dart'; part 'application_management/drafts/locked_block_draft.dart';
part 'application_management/updates/locked_block_update.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/results/owner_settings_update_result.dart';
part 'application_management/updates/owner_settings_update.dart'; part 'application_management/updates/owner_settings_update.dart';
part 'application_management/results/notice_acknowledgement_result.dart'; part 'application_management/results/notice_acknowledgement_result.dart';

View file

@ -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;
}

View file

@ -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. /// 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. /// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
Future<ApplicationResult<ProjectProfile>> createProject({ Future<ApplicationResult<ProjectProfile>> createProject({