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 _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<TaskFormScreen> {
@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<TaskFormScreen> {
_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<TaskFormScreen> {
}
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(
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<TaskFormScreen> {
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<TaskFormScreen> {
],
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<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() {
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<void> _onSave() async {
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 }

View File

@@ -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",

View File

@@ -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:

View File

@@ -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';