feat(02-03): create task providers, form screen with frequency and effort selectors

- TaskActions AsyncNotifier for create, update, delete, complete task mutations
- tasksInRoomProvider manual StreamProvider.family wrapping TasksDao.watchTasksInRoom
- TaskFormScreen with name, frequency (10 presets + custom), effort (3-way segmented),
  description, and initial due date picker (German DD.MM.YYYY format)
- Custom frequency: number + unit picker (Tage/Wochen/Monate)
- Calendar-anchored intervals auto-set anchorDay from due date
- Edit mode loads existing task and pre-fills all fields
- 19 new German localization keys for task form, delete, and empty state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 22:07:53 +01:00
parent 519a56bef7
commit 652ff0123f
7 changed files with 756 additions and 8 deletions

View File

@@ -1,18 +1,442 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; 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';
/// Placeholder for the task creation/edit form. import '../../../core/database/database.dart';
/// Will be fully implemented in Plan 03. import '../../../core/providers/database_provider.dart';
class TaskFormScreen extends StatelessWidget { import '../../../l10n/app_localizations.dart';
const TaskFormScreen({super.key, this.roomId, this.taskId}); import '../domain/effort_level.dart';
import '../domain/frequency.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? roomId;
final int? taskId; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Aufgabe')), appBar: AppBar(
body: const Center(child: Text('Demnächst verfügbar')), 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),
],
),
),
);
}
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);
}
}
}
}
/// Unit options for custom frequency input.
enum _CustomUnit { days, weeks, months }

View File

@@ -0,0 +1,65 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:household_keeper/core/database/database.dart';
import 'package:household_keeper/core/providers/database_provider.dart';
import 'package:household_keeper/features/tasks/domain/effort_level.dart';
import 'package:household_keeper/features/tasks/domain/frequency.dart';
part 'task_providers.g.dart';
/// Stream provider family for tasks in a specific room, sorted by due date.
///
/// Defined manually because riverpod_generator has trouble with drift's
/// generated [Task] type in family provider return types.
final tasksInRoomProvider =
StreamProvider.family.autoDispose<List<Task>, int>((ref, roomId) {
final db = ref.watch(appDatabaseProvider);
return db.tasksDao.watchTasksInRoom(roomId);
});
/// Notifier for task mutations: create, update, delete, complete.
@riverpod
class TaskActions extends _$TaskActions {
@override
FutureOr<void> build() {}
Future<int> createTask({
required int roomId,
required String name,
String? description,
required IntervalType intervalType,
required int intervalDays,
int? anchorDay,
required EffortLevel effortLevel,
required DateTime nextDueDate,
}) async {
final db = ref.read(appDatabaseProvider);
return db.tasksDao.insertTask(TasksCompanion.insert(
roomId: roomId,
name: name,
description: Value(description),
intervalType: intervalType,
intervalDays: Value(intervalDays),
anchorDay: Value(anchorDay),
effortLevel: effortLevel,
nextDueDate: nextDueDate,
));
}
Future<void> updateTask(Task task) async {
final db = ref.read(appDatabaseProvider);
await db.tasksDao.updateTask(task);
}
Future<void> deleteTask(int taskId) async {
final db = ref.read(appDatabaseProvider);
await db.tasksDao.deleteTask(taskId);
}
Future<void> completeTask(int taskId) async {
final db = ref.read(appDatabaseProvider);
await db.tasksDao.completeTask(taskId);
}
}

View File

@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'task_providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Notifier for task mutations: create, update, delete, complete.
@ProviderFor(TaskActions)
final taskActionsProvider = TaskActionsProvider._();
/// Notifier for task mutations: create, update, delete, complete.
final class TaskActionsProvider
extends $AsyncNotifierProvider<TaskActions, void> {
/// Notifier for task mutations: create, update, delete, complete.
TaskActionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'taskActionsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$taskActionsHash();
@$internal
@override
TaskActions create() => TaskActions();
}
String _$taskActionsHash() => r'62f1739263e3cfb379b83de10d712b17fd087f92';
/// Notifier for task mutations: create, update, delete, complete.
abstract class _$TaskActions extends $AsyncNotifier<void> {
FutureOr<void> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<void>, void>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<void>, void>,
AsyncValue<void>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -39,5 +39,25 @@
"count": { "type": "int" } "count": { "type": "int" }
} }
}, },
"cancel": "Abbrechen" "cancel": "Abbrechen",
"taskFormCreateTitle": "Aufgabe erstellen",
"taskFormEditTitle": "Aufgabe bearbeiten",
"taskFormNameLabel": "Aufgabenname",
"taskFormNameHint": "z.B. Staubsaugen, Fenster putzen...",
"taskFormNameRequired": "Bitte einen Namen eingeben",
"taskFormFrequencyLabel": "Wiederholung",
"taskFormFrequencyCustom": "Benutzerdefiniert",
"taskFormFrequencyEvery": "Alle",
"taskFormFrequencyUnitDays": "Tage",
"taskFormFrequencyUnitWeeks": "Wochen",
"taskFormFrequencyUnitMonths": "Monate",
"taskFormEffortLabel": "Aufwand",
"taskFormDescriptionLabel": "Beschreibung (optional)",
"taskFormDueDateLabel": "Erstes F\u00e4lligkeitsdatum",
"taskDeleteConfirmTitle": "Aufgabe l\u00f6schen?",
"taskDeleteConfirmMessage": "Die Aufgabe wird unwiderruflich gel\u00f6scht.",
"taskDeleteConfirmAction": "L\u00f6schen",
"taskEmptyTitle": "Noch keine Aufgaben",
"taskEmptyMessage": "Erstelle die erste Aufgabe f\u00fcr diesen Raum.",
"taskEmptyAction": "Aufgabe erstellen"
} }

View File

@@ -273,6 +273,126 @@ abstract class AppLocalizations {
/// In de, this message translates to: /// In de, this message translates to:
/// **'Abbrechen'** /// **'Abbrechen'**
String get cancel; String get cancel;
/// No description provided for @taskFormCreateTitle.
///
/// In de, this message translates to:
/// **'Aufgabe erstellen'**
String get taskFormCreateTitle;
/// No description provided for @taskFormEditTitle.
///
/// In de, this message translates to:
/// **'Aufgabe bearbeiten'**
String get taskFormEditTitle;
/// No description provided for @taskFormNameLabel.
///
/// In de, this message translates to:
/// **'Aufgabenname'**
String get taskFormNameLabel;
/// No description provided for @taskFormNameHint.
///
/// In de, this message translates to:
/// **'z.B. Staubsaugen, Fenster putzen...'**
String get taskFormNameHint;
/// No description provided for @taskFormNameRequired.
///
/// In de, this message translates to:
/// **'Bitte einen Namen eingeben'**
String get taskFormNameRequired;
/// No description provided for @taskFormFrequencyLabel.
///
/// In de, this message translates to:
/// **'Wiederholung'**
String get taskFormFrequencyLabel;
/// No description provided for @taskFormFrequencyCustom.
///
/// In de, this message translates to:
/// **'Benutzerdefiniert'**
String get taskFormFrequencyCustom;
/// No description provided for @taskFormFrequencyEvery.
///
/// In de, this message translates to:
/// **'Alle'**
String get taskFormFrequencyEvery;
/// No description provided for @taskFormFrequencyUnitDays.
///
/// In de, this message translates to:
/// **'Tage'**
String get taskFormFrequencyUnitDays;
/// No description provided for @taskFormFrequencyUnitWeeks.
///
/// In de, this message translates to:
/// **'Wochen'**
String get taskFormFrequencyUnitWeeks;
/// No description provided for @taskFormFrequencyUnitMonths.
///
/// In de, this message translates to:
/// **'Monate'**
String get taskFormFrequencyUnitMonths;
/// No description provided for @taskFormEffortLabel.
///
/// In de, this message translates to:
/// **'Aufwand'**
String get taskFormEffortLabel;
/// No description provided for @taskFormDescriptionLabel.
///
/// In de, this message translates to:
/// **'Beschreibung (optional)'**
String get taskFormDescriptionLabel;
/// No description provided for @taskFormDueDateLabel.
///
/// In de, this message translates to:
/// **'Erstes Fälligkeitsdatum'**
String get taskFormDueDateLabel;
/// No description provided for @taskDeleteConfirmTitle.
///
/// In de, this message translates to:
/// **'Aufgabe löschen?'**
String get taskDeleteConfirmTitle;
/// No description provided for @taskDeleteConfirmMessage.
///
/// In de, this message translates to:
/// **'Die Aufgabe wird unwiderruflich gelöscht.'**
String get taskDeleteConfirmMessage;
/// No description provided for @taskDeleteConfirmAction.
///
/// In de, this message translates to:
/// **'Löschen'**
String get taskDeleteConfirmAction;
/// No description provided for @taskEmptyTitle.
///
/// In de, this message translates to:
/// **'Noch keine Aufgaben'**
String get taskEmptyTitle;
/// No description provided for @taskEmptyMessage.
///
/// In de, this message translates to:
/// **'Erstelle die erste Aufgabe für diesen Raum.'**
String get taskEmptyMessage;
/// No description provided for @taskEmptyAction.
///
/// In de, this message translates to:
/// **'Aufgabe erstellen'**
String get taskEmptyAction;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View File

@@ -103,4 +103,65 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get cancel => 'Abbrechen'; String get cancel => 'Abbrechen';
@override
String get taskFormCreateTitle => 'Aufgabe erstellen';
@override
String get taskFormEditTitle => 'Aufgabe bearbeiten';
@override
String get taskFormNameLabel => 'Aufgabenname';
@override
String get taskFormNameHint => 'z.B. Staubsaugen, Fenster putzen...';
@override
String get taskFormNameRequired => 'Bitte einen Namen eingeben';
@override
String get taskFormFrequencyLabel => 'Wiederholung';
@override
String get taskFormFrequencyCustom => 'Benutzerdefiniert';
@override
String get taskFormFrequencyEvery => 'Alle';
@override
String get taskFormFrequencyUnitDays => 'Tage';
@override
String get taskFormFrequencyUnitWeeks => 'Wochen';
@override
String get taskFormFrequencyUnitMonths => 'Monate';
@override
String get taskFormEffortLabel => 'Aufwand';
@override
String get taskFormDescriptionLabel => 'Beschreibung (optional)';
@override
String get taskFormDueDateLabel => 'Erstes Fälligkeitsdatum';
@override
String get taskDeleteConfirmTitle => 'Aufgabe löschen?';
@override
String get taskDeleteConfirmMessage =>
'Die Aufgabe wird unwiderruflich gelöscht.';
@override
String get taskDeleteConfirmAction => 'Löschen';
@override
String get taskEmptyTitle => 'Noch keine Aufgaben';
@override
String get taskEmptyMessage => 'Erstelle die erste Aufgabe für diesen Raum.';
@override
String get taskEmptyAction => 'Aufgabe erstellen';
} }

View File

@@ -20,7 +20,6 @@ void main() {
overrides: [ overrides: [
// Override the stream provider to return an empty list immediately // Override the stream provider to return an empty list immediately
// so that the rooms screen shows the empty state without needing a DB. // so that the rooms screen shows the empty state without needing a DB.
// ignore: scoped_providers_should_specify_dependencies
roomWithStatsListProvider.overrideWith( roomWithStatsListProvider.overrideWith(
(ref) => Stream.value([]), (ref) => Stream.value([]),
), ),