focus-flow/apps/focus_flow_flutter/lib/widgets/dialogs/settings_dialog.dart
pyr0ball 6f0da3c8aa 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: #16
2026-07-07 14:38:16 -07:00

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