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.
|
/// 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,
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
},
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
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.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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
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(
|
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 {},
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// 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({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue