From 6f0da3c8aa849ea80cef767dcd36ed987b33217d Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 7 Jul 2026 14:38:16 -0700 Subject: [PATCH] feat(ui): wire Settings dialog to persisted OwnerSettings Adds a getOwnerSettings read query to scheduler_core (previously only an internal helper existed) and an updateOwnerSettings command on the Flutter command controller, then builds a shared Settings dialog surfacing timezone, day window, compact mode, and backlog staleness thresholds. Both existing no-op entry points (sidebar nav item, top bar button) now open the same dialog and persist edits through the existing OwnerSettings/BacklogStalenessSettings backend. Per the design spec (circuitforge-plans/focus-flow/superpowers/specs/ 2026-07-07-settings-screen-design.md), the compact-mode toggle persists but is disclosed in-UI as not yet affecting Today rendering, since that wiring is separate scope (#11). Closes: https://git.opensourcesolarpunk.com/eva/focus-flow/issues/16 --- .../lib/app/demo_scheduler_composition.dart | 14 + .../lib/app/focus_flow_app.dart | 1 + .../lib/app/home/focus_flow_home_state.dart | 46 +++ .../lib/app/home/today_screen_scaffold.dart | 5 + .../app/home/today_screen_scaffold_state.dart | 1 + .../app/persistent_scheduler_composition.dart | 17 + .../lib/app/scheduler_app_composition.dart | 3 + .../scheduler_command_controller_impl.dart | 39 ++- .../lib/widgets/dialogs/settings_dialog.dart | 317 ++++++++++++++++++ .../lib/widgets/sidebar.dart | 10 +- .../lib/widgets/top_bar.dart | 6 +- .../top_bar/top_bar_settings_button.dart | 8 +- .../test/app/settings_test.dart | 115 +++++++ .../test/persistent_composition_test.dart | 1 + .../application/application_management.dart | 1 + .../requests/get_owner_settings_request.dart | 14 + .../v1_application_management_use_cases.dart | 18 + 17 files changed, 606 insertions(+), 10 deletions(-) create mode 100644 apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart create mode 100644 apps/focus_flow_flutter/test/app/settings_test.dart create mode 100644 packages/scheduler_core/lib/src/application/application_management/requests/get_owner_settings_request.dart diff --git a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart index 55ba36a..7f01706 100644 --- a/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart @@ -192,6 +192,19 @@ class DemoSchedulerComposition implements SchedulerAppComposition { ); } + /// Creates a generic read controller for the settings screen. + @override + UiReadController createOwnerSettingsController() { + return ApplicationReadController( + 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, diff --git a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart index 3fa8a6c..05af748 100644 --- a/apps/focus_flow_flutter/lib/app/focus_flow_app.dart +++ b/apps/focus_flow_flutter/lib/app/focus_flow_app.dart @@ -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'; diff --git a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart index 7385d21..fdc4d6d 100644 --- a/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/focus_flow_home_state.dart @@ -22,6 +22,10 @@ class _FocusFlowHomeState extends State { /// This field is part of the explicit state that downstream scheduling, persistence, or UI code reads when composing behavior. late final UiReadController 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 ownerSettingsController; + /// Screen currently shown in the main content area. var _activeScreen = SidebarScreen.today; @@ -36,6 +40,8 @@ class _FocusFlowHomeState extends State { 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 { 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 { commandController.dispose(); controller.dispose(); backlogController.dispose(); + ownerSettingsController.dispose(); selectedDateController.dispose(); unawaited(widget.composition.dispose()); super.dispose(); @@ -65,6 +74,7 @@ class _FocusFlowHomeState extends State { Future _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 { ); } + /// Opens the settings dialog seeded with the current owner settings and + /// applies the confirmed patch, if any. + Future _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( + 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 { activeScreen: _activeScreen, onSelectToday: _showToday, onSelectBacklog: _showBacklog, + onOpenSettings: () { + unawaited(_openSettings()); + }, ), child: switch (_activeScreen) { SidebarScreen.today => _buildTodayContent(context), @@ -253,6 +293,9 @@ class _FocusFlowHomeState extends State { onChooseDate: () { unawaited(_chooseDate()); }, + onOpenSettings: () { + unawaited(_openSettings()); + }, ), TodayScreenReady(:final data) => _TodayScreenScaffold( data: data, @@ -271,6 +314,9 @@ class _FocusFlowHomeState extends State { onChooseDate: () { unawaited(_chooseDate()); }, + onOpenSettings: () { + unawaited(_openSettings()); + }, ), }; }, diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart index b0038b0..264bb2d 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold.dart @@ -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 diff --git a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart index 8fac12b..ecaf8fd 100644 --- a/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart +++ b/apps/focus_flow_flutter/lib/app/home/today_screen_scaffold_state.dart @@ -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) diff --git a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart index 9717e29..5daaa5a 100644 --- a/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart +++ b/apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart @@ -222,6 +222,22 @@ final class PersistentSchedulerComposition implements SchedulerAppComposition { ); } + /// Creates a read controller for the settings screen. + @override + UiReadController createOwnerSettingsController() { + scheduler_core.logger.finer( + () => 'Creating owner settings read controller.', + ); + return ApplicationReadController( + 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, diff --git a/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart b/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart index db31660..8dfb367 100644 --- a/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart +++ b/apps/focus_flow_flutter/lib/app/scheduler_app_composition.dart @@ -36,6 +36,9 @@ abstract interface class SchedulerAppComposition { /// Creates a read controller for the backlog screen. UiReadController createBacklogController(); + /// Creates a read controller for the settings screen. + UiReadController createOwnerSettingsController(); + /// Creates the command controller used by interactive scheduler controls. SchedulerCommandController createCommandController({ required ReadRefresh refreshReads, diff --git a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart index 5b7eaa7..b7ea4cb 100644 --- a/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart +++ b/apps/focus_flow_flutter/lib/controllers/command/scheduler_command_controller_impl.dart @@ -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.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 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( + 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 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 _run({ + Future _run({ required String label, required String successMessage, required bool refreshAfterSuccess, - required Future> Function( - String operationId, - ) - action, + required Future> Function(String operationId) action, }) async { final operationId = _nextOperationId(); logger.debug( diff --git a/apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart b/apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart new file mode 100644 index 0000000..8948489 --- /dev/null +++ b/apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart @@ -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 = [ + '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 createState() => _SettingsDialogState(); +} + +/// Private implementation type for `_SettingsDialogState` in this library. +class _SettingsDialogState extends State { + 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 _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 _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( + 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')}'; +} diff --git a/apps/focus_flow_flutter/lib/widgets/sidebar.dart b/apps/focus_flow_flutter/lib/widgets/sidebar.dart index 792119a..9eb9e03 100644 --- a/apps/focus_flow_flutter/lib/widgets/sidebar.dart +++ b/apps/focus_flow_flutter/lib/widgets/sidebar.dart @@ -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, + ), ], ), ), diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar.dart b/apps/focus_flow_flutter/lib/widgets/top_bar.dart index e5f3acd..2a18ce5 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar.dart @@ -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), ], ), ); diff --git a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart index c356dcb..4b42f04 100644 --- a/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart +++ b/apps/focus_flow_flutter/lib/widgets/top_bar/top_bar_settings_button.dart @@ -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( diff --git a/apps/focus_flow_flutter/test/app/settings_test.dart b/apps/focus_flow_flutter/test/app/settings_test.dart new file mode 100644 index 0000000..88406ea --- /dev/null +++ b/apps/focus_flow_flutter/test/app/settings_test.dart @@ -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( + 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 _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)); +} diff --git a/apps/focus_flow_flutter/test/persistent_composition_test.dart b/apps/focus_flow_flutter/test/persistent_composition_test.dart index 1d157a6..17d0a8b 100644 --- a/apps/focus_flow_flutter/test/persistent_composition_test.dart +++ b/apps/focus_flow_flutter/test/persistent_composition_test.dart @@ -552,6 +552,7 @@ SchedulerCommandController _commandController( }) { return SchedulerCommandController( commands: composition.commandUseCases, + management: composition.managementUseCases, contextFor: composition.context, selectedDate: () => selectedDate, refreshReads: () async {}, diff --git a/packages/scheduler_core/lib/src/application/application_management.dart b/packages/scheduler_core/lib/src/application/application_management.dart index 258d5e8..29ff8a6 100644 --- a/packages/scheduler_core/lib/src/application/application_management.dart +++ b/packages/scheduler_core/lib/src/application/application_management.dart @@ -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'; diff --git a/packages/scheduler_core/lib/src/application/application_management/requests/get_owner_settings_request.dart b/packages/scheduler_core/lib/src/application/application_management/requests/get_owner_settings_request.dart new file mode 100644 index 0000000..cf52ed6 --- /dev/null +++ b/packages/scheduler_core/lib/src/application/application_management/requests/get_owner_settings_request.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; +} diff --git a/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart index 1b8ea36..9056b8e 100644 --- a/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart +++ b/packages/scheduler_core/lib/src/application/application_management/use_cases/v1_application_management_use_cases.dart @@ -116,6 +116,24 @@ class V1ApplicationManagementUseCases { ); } + /// Load the effective owner settings, falling back to defaults when none + /// have been persisted yet. + Future> 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> createProject({