From 8a0b69b688a5e29b39fc3962a55d9796e4ac1cc8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 18 Mar 2026 22:45:38 +0100 Subject: [PATCH] feat(09-01): rework frequency picker with shortcut chips and freeform picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 10-chip grid + hidden Custom mode with 4 shortcut chips (Täglich, Wöchentlich, Alle 2 Wochen, Monatlich) - Always-visible freeform 'Alle [N] [Tage/Wochen/Monate]' picker row below chips - Bidirectional sync: tapping chip populates picker; editing picker recalculates chip highlight - _resolveFrequency() now reads exclusively from picker (single source of truth) - Edit mode correctly loads all 8 IntervalType values including quarterly and yearly - Add l10n keys frequencyShortcutDaily/Weekly/Biweekly/Monthly to app_de.arb --- .../tasks/presentation/task_form_screen.dart | 254 ++++++++++-------- lib/l10n/app_de.arb | 4 + lib/l10n/app_localizations.dart | 24 ++ lib/l10n/app_localizations_de.dart | 12 + 4 files changed, 179 insertions(+), 115 deletions(-) diff --git a/lib/features/tasks/presentation/task_form_screen.dart b/lib/features/tasks/presentation/task_form_screen.dart index b4b7769..db91974 100644 --- a/lib/features/tasks/presentation/task_form_screen.dart +++ b/lib/features/tasks/presentation/task_form_screen.dart @@ -32,11 +32,10 @@ class _TaskFormScreenState extends ConsumerState { final _formKey = GlobalKey(); final _nameController = TextEditingController(); final _descriptionController = TextEditingController(); - final _customIntervalController = TextEditingController(text: '2'); + final _customIntervalController = TextEditingController(text: '1'); - FrequencyInterval? _selectedPreset; - bool _isCustomFrequency = false; - _CustomUnit _customUnit = _CustomUnit.days; + _ShortcutFrequency? _activeShortcut; + _CustomUnit _customUnit = _CustomUnit.weeks; EffortLevel _effortLevel = EffortLevel.medium; DateTime _dueDate = DateTime.now(); Task? _existingTask; @@ -45,7 +44,9 @@ class _TaskFormScreenState extends ConsumerState { @override void initState() { super.initState(); - _selectedPreset = FrequencyInterval.presets[3]; // Default: weekly + _activeShortcut = _ShortcutFrequency.weekly; + _customIntervalController.text = '1'; + _customUnit = _CustomUnit.weeks; _dueDate = _dateOnly(DateTime.now()); if (widget.isEditing) { _loadExistingTask(); @@ -65,38 +66,45 @@ class _TaskFormScreenState extends ConsumerState { _effortLevel = task.effortLevel; _dueDate = task.nextDueDate; - // Find matching preset - _selectedPreset = null; - _isCustomFrequency = true; - for (final preset in FrequencyInterval.presets) { - if (preset.intervalType == task.intervalType && - preset.days == task.intervalDays) { - _selectedPreset = preset; - _isCustomFrequency = false; - break; - } - } - - if (_isCustomFrequency) { - // Determine custom unit from stored interval - switch (task.intervalType) { - case IntervalType.everyNMonths: - _customUnit = _CustomUnit.months; - _customIntervalController.text = task.intervalDays.toString(); - case IntervalType.monthly: - _customUnit = _CustomUnit.months; - _customIntervalController.text = '1'; - case IntervalType.weekly: + // Populate picker from stored interval + switch (task.intervalType) { + case IntervalType.daily: + _customUnit = _CustomUnit.days; + _customIntervalController.text = '1'; + case IntervalType.everyNDays: + // Check if it's a clean week multiple + if (task.intervalDays % 7 == 0) { _customUnit = _CustomUnit.weeks; - _customIntervalController.text = '1'; - case IntervalType.biweekly: - _customUnit = _CustomUnit.weeks; - _customIntervalController.text = '2'; - default: + _customIntervalController.text = (task.intervalDays ~/ 7).toString(); + } else { _customUnit = _CustomUnit.days; _customIntervalController.text = task.intervalDays.toString(); - } + } + case IntervalType.weekly: + _customUnit = _CustomUnit.weeks; + _customIntervalController.text = '1'; + case IntervalType.biweekly: + _customUnit = _CustomUnit.weeks; + _customIntervalController.text = '2'; + case IntervalType.monthly: + _customUnit = _CustomUnit.months; + _customIntervalController.text = '1'; + case IntervalType.everyNMonths: + _customUnit = _CustomUnit.months; + _customIntervalController.text = task.intervalDays.toString(); + case IntervalType.quarterly: + _customUnit = _CustomUnit.months; + _customIntervalController.text = '3'; + case IntervalType.yearly: + _customUnit = _CustomUnit.months; + _customIntervalController.text = '12'; } + + // Detect matching shortcut chip + _activeShortcut = _ShortcutFrequency.fromPickerValues( + int.tryParse(_customIntervalController.text) ?? 1, + _customUnit, + ); }); } @@ -224,59 +232,52 @@ class _TaskFormScreenState extends ConsumerState { } Widget _buildFrequencySelector(AppLocalizations l10n, ThemeData theme) { - final items = []; - - // Preset intervals - for (final preset in FrequencyInterval.presets) { - items.add( - ChoiceChip( - label: Text(preset.label()), - selected: !_isCustomFrequency && _selectedPreset == preset, - onSelected: (selected) { - if (selected) { - setState(() { - _selectedPreset = preset; - _isCustomFrequency = false; - }); - } - }, - ), - ); - } - - // Custom option - items.add( - ChoiceChip( - label: Text(l10n.taskFormFrequencyCustom), - selected: _isCustomFrequency, - onSelected: (selected) { - if (selected) { - setState(() { - _isCustomFrequency = true; - _selectedPreset = null; - }); - } - }, - ), - ); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Shortcut chips row (always visible) Wrap( spacing: 8, runSpacing: 4, - children: items, + children: [ + for (final shortcut in _ShortcutFrequency.values) + ChoiceChip( + label: Text(_shortcutLabel(shortcut, l10n)), + selected: _activeShortcut == shortcut, + onSelected: (selected) { + if (selected) { + final values = shortcut.toPickerValues(); + setState(() { + _activeShortcut = shortcut; + _customIntervalController.text = values.number.toString(); + _customUnit = values.unit; + }); + } + }, + ), + ], ), - if (_isCustomFrequency) ...[ - const SizedBox(height: 12), - _buildCustomFrequencyInput(l10n, theme), - ], + const SizedBox(height: 12), + // Freeform picker row (ALWAYS visible — not conditional) + _buildFrequencyPickerRow(l10n, theme), ], ); } - Widget _buildCustomFrequencyInput(AppLocalizations l10n, ThemeData theme) { + String _shortcutLabel(_ShortcutFrequency shortcut, AppLocalizations l10n) { + switch (shortcut) { + case _ShortcutFrequency.daily: + return l10n.frequencyShortcutDaily; + case _ShortcutFrequency.weekly: + return l10n.frequencyShortcutWeekly; + case _ShortcutFrequency.biweekly: + return l10n.frequencyShortcutBiweekly; + case _ShortcutFrequency.monthly: + return l10n.frequencyShortcutMonthly; + } + } + + Widget _buildFrequencyPickerRow(AppLocalizations l10n, ThemeData theme) { return Row( children: [ Text( @@ -294,6 +295,14 @@ class _TaskFormScreenState extends ConsumerState { decoration: const InputDecoration( contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), ), + onChanged: (value) { + setState(() { + _activeShortcut = _ShortcutFrequency.fromPickerValues( + int.tryParse(value) ?? 1, + _customUnit, + ); + }); + }, ), ), const SizedBox(width: 8), @@ -315,8 +324,13 @@ class _TaskFormScreenState extends ConsumerState { ], selected: {_customUnit}, onSelectionChanged: (newSelection) { + final newUnit = newSelection.first; setState(() { - _customUnit = newSelection.first; + _customUnit = newUnit; + _activeShortcut = _ShortcutFrequency.fromPickerValues( + int.tryParse(_customIntervalController.text) ?? 1, + newUnit, + ); }); }, ), @@ -374,53 +388,32 @@ class _TaskFormScreenState extends ConsumerState { } } - /// Resolve the frequency from either selected preset or custom input. + /// Resolve the frequency from the freeform picker (single source of truth). + /// The picker is always the source of truth; shortcut chips just populate it. ({IntervalType type, int days, int? anchorDay}) _resolveFrequency() { - if (!_isCustomFrequency && _selectedPreset != null) { - final preset = _selectedPreset!; - // For calendar-anchored intervals, set anchorDay to due date's day - int? anchorDay; - if (_isCalendarAnchored(preset.intervalType)) { - anchorDay = _dueDate.day; - } - return ( - type: preset.intervalType, - days: preset.days, - anchorDay: anchorDay, - ); - } - - // Custom frequency final number = int.tryParse(_customIntervalController.text) ?? 1; switch (_customUnit) { case _CustomUnit.days: - return ( - type: IntervalType.everyNDays, - days: number, - anchorDay: null, - ); + if (number == 1) { + return (type: IntervalType.daily, days: 1, anchorDay: null); + } + return (type: IntervalType.everyNDays, days: number, anchorDay: null); case _CustomUnit.weeks: - return ( - type: IntervalType.everyNDays, - days: number * 7, - anchorDay: null, - ); + if (number == 1) { + return (type: IntervalType.weekly, days: 1, anchorDay: null); + } + if (number == 2) { + return (type: IntervalType.biweekly, days: 14, anchorDay: null); + } + return (type: IntervalType.everyNDays, days: number * 7, anchorDay: null); case _CustomUnit.months: - return ( - type: IntervalType.everyNMonths, - days: number, - anchorDay: _dueDate.day, - ); + if (number == 1) { + return (type: IntervalType.monthly, days: 1, anchorDay: _dueDate.day); + } + return (type: IntervalType.everyNMonths, days: number, anchorDay: _dueDate.day); } } - bool _isCalendarAnchored(IntervalType type) { - return type == IntervalType.monthly || - type == IntervalType.everyNMonths || - type == IntervalType.quarterly || - type == IntervalType.yearly; - } - Future _onSave() async { if (!_formKey.currentState!.validate()) return; @@ -507,5 +500,36 @@ class _TaskFormScreenState extends ConsumerState { } } -/// Unit options for custom frequency input. +/// Shortcut frequency options for quick selection chips. +enum _ShortcutFrequency { + daily, + weekly, + biweekly, + monthly; + + /// Returns the picker values (number + unit) that this shortcut represents. + ({int number, _CustomUnit unit}) toPickerValues() { + switch (this) { + case _ShortcutFrequency.daily: + return (number: 1, unit: _CustomUnit.days); + case _ShortcutFrequency.weekly: + return (number: 1, unit: _CustomUnit.weeks); + case _ShortcutFrequency.biweekly: + return (number: 2, unit: _CustomUnit.weeks); + case _ShortcutFrequency.monthly: + return (number: 1, unit: _CustomUnit.months); + } + } + + /// Returns the matching shortcut for given picker values, or null if no match. + static _ShortcutFrequency? fromPickerValues(int number, _CustomUnit unit) { + if (number == 1 && unit == _CustomUnit.days) return _ShortcutFrequency.daily; + if (number == 1 && unit == _CustomUnit.weeks) return _ShortcutFrequency.weekly; + if (number == 2 && unit == _CustomUnit.weeks) return _ShortcutFrequency.biweekly; + if (number == 1 && unit == _CustomUnit.months) return _ShortcutFrequency.monthly; + return null; + } +} + +/// Unit options for freeform frequency picker. enum _CustomUnit { days, weeks, months } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 2c39f8d..276ac2c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -48,6 +48,10 @@ "taskFormFrequencyLabel": "Wiederholung", "taskFormFrequencyCustom": "Benutzerdefiniert", "taskFormFrequencyEvery": "Alle", + "frequencyShortcutDaily": "Täglich", + "frequencyShortcutWeekly": "Wöchentlich", + "frequencyShortcutBiweekly": "Alle 2 Wochen", + "frequencyShortcutMonthly": "Monatlich", "taskFormFrequencyUnitDays": "Tage", "taskFormFrequencyUnitWeeks": "Wochen", "taskFormFrequencyUnitMonths": "Monate", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6d62794..8bea37c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -322,6 +322,30 @@ abstract class AppLocalizations { /// **'Alle'** String get taskFormFrequencyEvery; + /// No description provided for @frequencyShortcutDaily. + /// + /// In de, this message translates to: + /// **'Täglich'** + String get frequencyShortcutDaily; + + /// No description provided for @frequencyShortcutWeekly. + /// + /// In de, this message translates to: + /// **'Wöchentlich'** + String get frequencyShortcutWeekly; + + /// No description provided for @frequencyShortcutBiweekly. + /// + /// In de, this message translates to: + /// **'Alle 2 Wochen'** + String get frequencyShortcutBiweekly; + + /// No description provided for @frequencyShortcutMonthly. + /// + /// In de, this message translates to: + /// **'Monatlich'** + String get frequencyShortcutMonthly; + /// No description provided for @taskFormFrequencyUnitDays. /// /// In de, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 0a5ec41..905d860 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -128,6 +128,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get taskFormFrequencyEvery => 'Alle'; + @override + String get frequencyShortcutDaily => 'Täglich'; + + @override + String get frequencyShortcutWeekly => 'Wöchentlich'; + + @override + String get frequencyShortcutBiweekly => 'Alle 2 Wochen'; + + @override + String get frequencyShortcutMonthly => 'Monatlich'; + @override String get taskFormFrequencyUnitDays => 'Tage';