feat(09-01): rework frequency picker with shortcut chips and freeform picker

- 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
This commit is contained in:
2026-03-18 22:45:38 +01:00
parent 1fd6c05f0f
commit 8a0b69b688
4 changed files with 179 additions and 115 deletions

View File

@@ -32,11 +32,10 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
final _customIntervalController = TextEditingController(text: '2'); final _customIntervalController = TextEditingController(text: '1');
FrequencyInterval? _selectedPreset; _ShortcutFrequency? _activeShortcut;
bool _isCustomFrequency = false; _CustomUnit _customUnit = _CustomUnit.weeks;
_CustomUnit _customUnit = _CustomUnit.days;
EffortLevel _effortLevel = EffortLevel.medium; EffortLevel _effortLevel = EffortLevel.medium;
DateTime _dueDate = DateTime.now(); DateTime _dueDate = DateTime.now();
Task? _existingTask; Task? _existingTask;
@@ -45,7 +44,9 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedPreset = FrequencyInterval.presets[3]; // Default: weekly _activeShortcut = _ShortcutFrequency.weekly;
_customIntervalController.text = '1';
_customUnit = _CustomUnit.weeks;
_dueDate = _dateOnly(DateTime.now()); _dueDate = _dateOnly(DateTime.now());
if (widget.isEditing) { if (widget.isEditing) {
_loadExistingTask(); _loadExistingTask();
@@ -65,38 +66,45 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
_effortLevel = task.effortLevel; _effortLevel = task.effortLevel;
_dueDate = task.nextDueDate; _dueDate = task.nextDueDate;
// Find matching preset // Populate picker from stored interval
_selectedPreset = null; switch (task.intervalType) {
_isCustomFrequency = true; case IntervalType.daily:
for (final preset in FrequencyInterval.presets) { _customUnit = _CustomUnit.days;
if (preset.intervalType == task.intervalType && _customIntervalController.text = '1';
preset.days == task.intervalDays) { case IntervalType.everyNDays:
_selectedPreset = preset; // Check if it's a clean week multiple
_isCustomFrequency = false; if (task.intervalDays % 7 == 0) {
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:
_customUnit = _CustomUnit.weeks; _customUnit = _CustomUnit.weeks;
_customIntervalController.text = '1'; _customIntervalController.text = (task.intervalDays ~/ 7).toString();
case IntervalType.biweekly: } else {
_customUnit = _CustomUnit.weeks;
_customIntervalController.text = '2';
default:
_customUnit = _CustomUnit.days; _customUnit = _CustomUnit.days;
_customIntervalController.text = task.intervalDays.toString(); _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<TaskFormScreen> {
} }
Widget _buildFrequencySelector(AppLocalizations l10n, ThemeData theme) { Widget _buildFrequencySelector(AppLocalizations l10n, ThemeData theme) {
final items = <Widget>[];
// 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( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Shortcut chips row (always visible)
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 4, 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),
const SizedBox(height: 12), // Freeform picker row (ALWAYS visible — not conditional)
_buildCustomFrequencyInput(l10n, theme), _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( return Row(
children: [ children: [
Text( Text(
@@ -294,6 +295,14 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
decoration: const InputDecoration( decoration: const InputDecoration(
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
), ),
onChanged: (value) {
setState(() {
_activeShortcut = _ShortcutFrequency.fromPickerValues(
int.tryParse(value) ?? 1,
_customUnit,
);
});
},
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -315,8 +324,13 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
], ],
selected: {_customUnit}, selected: {_customUnit},
onSelectionChanged: (newSelection) { onSelectionChanged: (newSelection) {
final newUnit = newSelection.first;
setState(() { setState(() {
_customUnit = newSelection.first; _customUnit = newUnit;
_activeShortcut = _ShortcutFrequency.fromPickerValues(
int.tryParse(_customIntervalController.text) ?? 1,
newUnit,
);
}); });
}, },
), ),
@@ -374,53 +388,32 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
} }
} }
/// 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() { ({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; final number = int.tryParse(_customIntervalController.text) ?? 1;
switch (_customUnit) { switch (_customUnit) {
case _CustomUnit.days: case _CustomUnit.days:
return ( if (number == 1) {
type: IntervalType.everyNDays, return (type: IntervalType.daily, days: 1, anchorDay: null);
days: number, }
anchorDay: null, return (type: IntervalType.everyNDays, days: number, anchorDay: null);
);
case _CustomUnit.weeks: case _CustomUnit.weeks:
return ( if (number == 1) {
type: IntervalType.everyNDays, return (type: IntervalType.weekly, days: 1, anchorDay: null);
days: number * 7, }
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: case _CustomUnit.months:
return ( if (number == 1) {
type: IntervalType.everyNMonths, return (type: IntervalType.monthly, days: 1, anchorDay: _dueDate.day);
days: number, }
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<void> _onSave() async { Future<void> _onSave() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
@@ -507,5 +500,36 @@ class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
} }
} }
/// 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 } enum _CustomUnit { days, weeks, months }

View File

@@ -48,6 +48,10 @@
"taskFormFrequencyLabel": "Wiederholung", "taskFormFrequencyLabel": "Wiederholung",
"taskFormFrequencyCustom": "Benutzerdefiniert", "taskFormFrequencyCustom": "Benutzerdefiniert",
"taskFormFrequencyEvery": "Alle", "taskFormFrequencyEvery": "Alle",
"frequencyShortcutDaily": "Täglich",
"frequencyShortcutWeekly": "Wöchentlich",
"frequencyShortcutBiweekly": "Alle 2 Wochen",
"frequencyShortcutMonthly": "Monatlich",
"taskFormFrequencyUnitDays": "Tage", "taskFormFrequencyUnitDays": "Tage",
"taskFormFrequencyUnitWeeks": "Wochen", "taskFormFrequencyUnitWeeks": "Wochen",
"taskFormFrequencyUnitMonths": "Monate", "taskFormFrequencyUnitMonths": "Monate",

View File

@@ -322,6 +322,30 @@ abstract class AppLocalizations {
/// **'Alle'** /// **'Alle'**
String get taskFormFrequencyEvery; 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. /// No description provided for @taskFormFrequencyUnitDays.
/// ///
/// In de, this message translates to: /// In de, this message translates to:

View File

@@ -128,6 +128,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get taskFormFrequencyEvery => 'Alle'; 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 @override
String get taskFormFrequencyUnitDays => 'Tage'; String get taskFormFrequencyUnitDays => 'Tage';