diff --git a/lib/features/tasks/presentation/task_form_screen.dart b/lib/features/tasks/presentation/task_form_screen.dart index 722cd04..991b978 100644 --- a/lib/features/tasks/presentation/task_form_screen.dart +++ b/lib/features/tasks/presentation/task_form_screen.dart @@ -1,18 +1,442 @@ +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'; -/// Placeholder for the task creation/edit form. -/// Will be fully implemented in Plan 03. -class TaskFormScreen extends StatelessWidget { - const TaskFormScreen({super.key, this.roomId, this.taskId}); +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_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 createState() => _TaskFormScreenState(); +} + +class _TaskFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + 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 _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: const Text('Aufgabe')), - body: const Center(child: Text('Demnächst verfügbar')), + 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), + ], + ), + ), ); } + + Widget _buildFrequencySelector(AppLocalizations l10n, ThemeData theme) { + final items = []; + + // 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( + 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 _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 _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 } diff --git a/lib/features/tasks/presentation/task_providers.dart b/lib/features/tasks/presentation/task_providers.dart new file mode 100644 index 0000000..140d30d --- /dev/null +++ b/lib/features/tasks/presentation/task_providers.dart @@ -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, 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 build() {} + + Future 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 updateTask(Task task) async { + final db = ref.read(appDatabaseProvider); + await db.tasksDao.updateTask(task); + } + + Future deleteTask(int taskId) async { + final db = ref.read(appDatabaseProvider); + await db.tasksDao.deleteTask(taskId); + } + + Future completeTask(int taskId) async { + final db = ref.read(appDatabaseProvider); + await db.tasksDao.completeTask(taskId); + } +} diff --git a/lib/features/tasks/presentation/task_providers.g.dart b/lib/features/tasks/presentation/task_providers.g.dart new file mode 100644 index 0000000..28d3c79 --- /dev/null +++ b/lib/features/tasks/presentation/task_providers.g.dart @@ -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 { + /// 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 { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, void>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, void>, + AsyncValue, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e6fdc55..7b9f1d0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -39,5 +39,25 @@ "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" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c1c7990..eccf7cb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -273,6 +273,126 @@ abstract class AppLocalizations { /// In de, this message translates to: /// **'Abbrechen'** 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 diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 0be0575..807a17a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -103,4 +103,65 @@ class AppLocalizationsDe extends AppLocalizations { @override 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'; } diff --git a/test/shell/app_shell_test.dart b/test/shell/app_shell_test.dart index 9665d65..85be13a 100644 --- a/test/shell/app_shell_test.dart +++ b/test/shell/app_shell_test.dart @@ -20,7 +20,6 @@ void main() { overrides: [ // Override the stream provider to return an empty list immediately // so that the rooms screen shows the empty state without needing a DB. - // ignore: scoped_providers_should_specify_dependencies roomWithStatsListProvider.overrideWith( (ref) => Stream.value([]), ),