Files
HouseHoldKeaper/lib/features/tasks/presentation/task_form_screen.dart
Jean-Luc Makiola 8a0b69b688 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
2026-03-18 22:45:38 +01:00

536 lines
17 KiB
Dart

import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/database/database.dart';
import '../../../core/providers/database_provider.dart';
import '../../../l10n/app_localizations.dart';
import '../domain/effort_level.dart';
import '../domain/frequency.dart';
import 'task_history_sheet.dart';
import 'task_providers.dart';
/// Full-screen form for task creation and editing.
///
/// Pass [roomId] for create mode, or [taskId] for edit mode.
class TaskFormScreen extends ConsumerStatefulWidget {
final int? roomId;
final int? taskId;
const TaskFormScreen({super.key, this.roomId, this.taskId});
bool get isEditing => taskId != null;
@override
ConsumerState<TaskFormScreen> createState() => _TaskFormScreenState();
}
class _TaskFormScreenState extends ConsumerState<TaskFormScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _customIntervalController = TextEditingController(text: '1');
_ShortcutFrequency? _activeShortcut;
_CustomUnit _customUnit = _CustomUnit.weeks;
EffortLevel _effortLevel = EffortLevel.medium;
DateTime _dueDate = DateTime.now();
Task? _existingTask;
bool _isLoading = false;
@override
void initState() {
super.initState();
_activeShortcut = _ShortcutFrequency.weekly;
_customIntervalController.text = '1';
_customUnit = _CustomUnit.weeks;
_dueDate = _dateOnly(DateTime.now());
if (widget.isEditing) {
_loadExistingTask();
}
}
Future<void> _loadExistingTask() async {
final db = ref.read(appDatabaseProvider);
final task = await (db.select(db.tasks)
..where((t) => t.id.equals(widget.taskId!)))
.getSingle();
setState(() {
_existingTask = task;
_nameController.text = task.name;
_descriptionController.text = task.description ?? '';
_effortLevel = task.effortLevel;
_dueDate = task.nextDueDate;
// 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 = (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,
);
});
}
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_customIntervalController.dispose();
super.dispose();
}
int get _roomId => widget.roomId ?? _existingTask!.roomId;
static DateTime _dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Text(
widget.isEditing ? l10n.taskFormEditTitle : l10n.taskFormCreateTitle,
),
actions: [
IconButton(
onPressed: _isLoading ? null : _onSave,
icon: const Icon(Icons.check),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Name field (required, autofocus)
TextFormField(
controller: _nameController,
autofocus: !widget.isEditing,
maxLength: 200,
decoration: InputDecoration(
labelText: l10n.taskFormNameLabel,
hintText: l10n.taskFormNameHint,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return l10n.taskFormNameRequired;
}
return null;
},
),
const SizedBox(height: 16),
// Frequency selector
Text(
l10n.taskFormFrequencyLabel,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
_buildFrequencySelector(l10n, theme),
const SizedBox(height: 16),
// Effort selector
Text(
l10n.taskFormEffortLabel,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
_buildEffortSelector(),
const SizedBox(height: 16),
// Description (optional)
TextFormField(
controller: _descriptionController,
maxLines: 3,
decoration: InputDecoration(
labelText: l10n.taskFormDescriptionLabel,
),
),
const SizedBox(height: 16),
// Initial due date
Text(
l10n.taskFormDueDateLabel,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
_buildDueDatePicker(theme),
// History section (edit mode only)
if (widget.isEditing) ...[
const SizedBox(height: 24),
const Divider(),
ListTile(
leading: const Icon(Icons.history),
title: Text(l10n.taskHistoryTitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => showTaskHistorySheet(
context: context,
taskId: widget.taskId!,
),
),
// DELETE BUTTON
const Divider(),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
onPressed: _isLoading ? null : _onDelete,
icon: const Icon(Icons.delete_outline),
label: Text(l10n.taskDeleteConfirmAction),
),
),
],
],
),
),
);
}
Widget _buildFrequencySelector(AppLocalizations l10n, ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Shortcut chips row (always visible)
Wrap(
spacing: 8,
runSpacing: 4,
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;
});
}
},
),
],
),
const SizedBox(height: 12),
// Freeform picker row (ALWAYS visible — not conditional)
_buildFrequencyPickerRow(l10n, 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(
l10n.taskFormFrequencyEvery,
style: theme.textTheme.bodyLarge,
),
const SizedBox(width: 8),
SizedBox(
width: 60,
child: TextFormField(
controller: _customIntervalController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textAlign: TextAlign.center,
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),
Expanded(
child: SegmentedButton<_CustomUnit>(
segments: [
ButtonSegment(
value: _CustomUnit.days,
label: Text(l10n.taskFormFrequencyUnitDays),
),
ButtonSegment(
value: _CustomUnit.weeks,
label: Text(l10n.taskFormFrequencyUnitWeeks),
),
ButtonSegment(
value: _CustomUnit.months,
label: Text(l10n.taskFormFrequencyUnitMonths),
),
],
selected: {_customUnit},
onSelectionChanged: (newSelection) {
final newUnit = newSelection.first;
setState(() {
_customUnit = newUnit;
_activeShortcut = _ShortcutFrequency.fromPickerValues(
int.tryParse(_customIntervalController.text) ?? 1,
newUnit,
);
});
},
),
),
],
);
}
Widget _buildEffortSelector() {
return SegmentedButton<EffortLevel>(
segments: EffortLevel.values
.map((e) => ButtonSegment(
value: e,
label: Text(e.label()),
))
.toList(),
selected: {_effortLevel},
onSelectionChanged: (newSelection) {
setState(() {
_effortLevel = newSelection.first;
});
},
);
}
Widget _buildDueDatePicker(ThemeData theme) {
final dateFormat = DateFormat('dd.MM.yyyy');
return InkWell(
onTap: _pickDueDate,
borderRadius: BorderRadius.circular(8),
child: InputDecorator(
decoration: const InputDecoration(
suffixIcon: Icon(Icons.calendar_today),
),
child: Text(
dateFormat.format(_dueDate),
style: theme.textTheme.bodyLarge,
),
),
);
}
Future<void> _pickDueDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _dueDate,
firstDate: DateTime(2020),
lastDate: DateTime(2100),
locale: const Locale('de'),
);
if (picked != null) {
setState(() {
_dueDate = _dateOnly(picked);
});
}
}
/// 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() {
final number = int.tryParse(_customIntervalController.text) ?? 1;
switch (_customUnit) {
case _CustomUnit.days:
if (number == 1) {
return (type: IntervalType.daily, days: 1, anchorDay: null);
}
return (type: IntervalType.everyNDays, days: number, anchorDay: null);
case _CustomUnit.weeks:
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:
if (number == 1) {
return (type: IntervalType.monthly, days: 1, anchorDay: _dueDate.day);
}
return (type: IntervalType.everyNMonths, days: number, anchorDay: _dueDate.day);
}
}
Future<void> _onSave() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final freq = _resolveFrequency();
final actions = ref.read(taskActionsProvider.notifier);
if (widget.isEditing && _existingTask != null) {
final updatedTask = _existingTask!.copyWith(
name: _nameController.text.trim(),
description: Value(_descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim()),
intervalType: freq.type,
intervalDays: freq.days,
anchorDay: Value(freq.anchorDay),
effortLevel: _effortLevel,
nextDueDate: _dueDate,
);
await actions.updateTask(updatedTask);
} else {
await actions.createTask(
roomId: _roomId,
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
intervalType: freq.type,
intervalDays: freq.days,
anchorDay: freq.anchorDay,
effortLevel: _effortLevel,
nextDueDate: _dueDate,
);
}
if (mounted) {
context.pop();
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _onDelete() async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.taskDeleteConfirmTitle),
content: Text(l10n.taskDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.taskDeleteConfirmAction),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _isLoading = true);
try {
await ref.read(taskActionsProvider.notifier).smartDeleteTask(widget.taskId!);
if (mounted) {
context.pop();
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
}
/// 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 }