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: #16
317 lines
10 KiB
Dart
317 lines
10 KiB
Dart
// 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')}';
|
|
}
|