Files
HouseHoldKeaper/lib/features/tasks/presentation/task_form_screen.dart
Jean-Luc Makiola 6133c977f5 feat(08-02): add delete button and confirmation dialog to TaskFormScreen
- Red FilledButton.icon with error color below history section (edit mode only)
- _onDelete shows AlertDialog with taskDeleteConfirmTitle/Message l10n strings
- Confirm calls smartDeleteTask and pops back to room task list
- Cancel dismisses dialog with no action
- Button disabled while _isLoading
- All 144 tests pass, dart analyze clean
2026-03-18 21:01:53 +01:00

512 lines
15 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: '2');
FrequencyInterval? _selectedPreset;
bool _isCustomFrequency = false;
_CustomUnit _customUnit = _CustomUnit.days;
EffortLevel _effortLevel = EffortLevel.medium;
DateTime _dueDate = DateTime.now();
Task? _existingTask;
bool _isLoading = false;
@override
void initState() {
super.initState();
_selectedPreset = FrequencyInterval.presets[3]; // Default: weekly
_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;
// 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:
_customUnit = _CustomUnit.weeks;
_customIntervalController.text = '1';
case IntervalType.biweekly:
_customUnit = _CustomUnit.weeks;
_customIntervalController.text = '2';
default:
_customUnit = _CustomUnit.days;
_customIntervalController.text = task.intervalDays.toString();
}
}
});
}
@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) {
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: [
Wrap(
spacing: 8,
runSpacing: 4,
children: items,
),
if (_isCustomFrequency) ...[
const SizedBox(height: 12),
_buildCustomFrequencyInput(l10n, theme),
],
],
);
}
Widget _buildCustomFrequencyInput(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),
),
),
),
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) {
setState(() {
_customUnit = newSelection.first;
});
},
),
),
],
);
}
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 either selected preset or custom input.
({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,
);
case _CustomUnit.weeks:
return (
type: IntervalType.everyNDays,
days: number * 7,
anchorDay: null,
);
case _CustomUnit.months:
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;
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);
}
}
}
}
/// Unit options for custom frequency input.
enum _CustomUnit { days, weeks, months }