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