diff --git a/lib/core/router/router.dart b/lib/core/router/router.dart index eac70a5..c97fae9 100644 --- a/lib/core/router/router.dart +++ b/lib/core/router/router.dart @@ -1,8 +1,11 @@ import 'package:go_router/go_router.dart'; import 'package:household_keeper/features/home/presentation/home_screen.dart'; +import 'package:household_keeper/features/rooms/presentation/room_form_screen.dart'; import 'package:household_keeper/features/rooms/presentation/rooms_screen.dart'; import 'package:household_keeper/features/settings/presentation/settings_screen.dart'; +import 'package:household_keeper/features/tasks/presentation/task_form_screen.dart'; +import 'package:household_keeper/features/tasks/presentation/task_list_screen.dart'; import 'package:household_keeper/shell/app_shell.dart'; final router = GoRouter( @@ -25,6 +28,46 @@ final router = GoRouter( GoRoute( path: '/rooms', builder: (context, state) => const RoomsScreen(), + routes: [ + GoRoute( + path: 'new', + builder: (context, state) => const RoomFormScreen(), + ), + GoRoute( + path: ':roomId', + builder: (context, state) { + final roomId = + int.parse(state.pathParameters['roomId']!); + return TaskListScreen(roomId: roomId); + }, + routes: [ + GoRoute( + path: 'edit', + builder: (context, state) { + final roomId = + int.parse(state.pathParameters['roomId']!); + return RoomFormScreen(roomId: roomId); + }, + ), + GoRoute( + path: 'tasks/new', + builder: (context, state) { + final roomId = + int.parse(state.pathParameters['roomId']!); + return TaskFormScreen(roomId: roomId); + }, + ), + GoRoute( + path: 'tasks/:taskId', + builder: (context, state) { + final taskId = + int.parse(state.pathParameters['taskId']!); + return TaskFormScreen(taskId: taskId); + }, + ), + ], + ), + ], ), ], ), diff --git a/lib/features/rooms/presentation/icon_picker_sheet.dart b/lib/features/rooms/presentation/icon_picker_sheet.dart new file mode 100644 index 0000000..2ba2aee --- /dev/null +++ b/lib/features/rooms/presentation/icon_picker_sheet.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import 'package:household_keeper/features/rooms/domain/room_icons.dart'; + +/// Shows a modal bottom sheet with a grid of curated Material Icons. +/// +/// Returns the selected icon name, or null if dismissed. +Future showIconPickerSheet({ + required BuildContext context, + String? selectedIconName, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => IconPickerSheet( + selectedIconName: selectedIconName, + onIconSelected: (name) => Navigator.of(context).pop(name), + ), + ); +} + +/// Grid of curated household Material Icons for room icon selection. +class IconPickerSheet extends StatelessWidget { + const IconPickerSheet({ + super.key, + this.selectedIconName, + required this.onIconSelected, + }); + + final String? selectedIconName; + final ValueChanged onIconSelected; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Text( + 'Symbol w\u00e4hlen', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 16), + GridView.count( + crossAxisCount: 5, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: curatedRoomIcons.map((entry) { + final isSelected = entry.name == selectedIconName; + return InkWell( + onTap: () => onIconSelected(entry.name), + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primaryContainer + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + entry.icon, + size: 28, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface, + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } +} diff --git a/lib/features/rooms/presentation/room_form_screen.dart b/lib/features/rooms/presentation/room_form_screen.dart new file mode 100644 index 0000000..4ce3cb4 --- /dev/null +++ b/lib/features/rooms/presentation/room_form_screen.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:household_keeper/core/providers/database_provider.dart'; +import 'package:household_keeper/features/rooms/domain/room_icons.dart'; +import 'package:household_keeper/features/rooms/presentation/icon_picker_sheet.dart'; +import 'package:household_keeper/features/rooms/presentation/room_providers.dart'; +import 'package:household_keeper/l10n/app_localizations.dart'; + +/// Full-screen form for creating and editing rooms. +/// +/// Pass [roomId] to edit an existing room, or leave null to create a new one. +class RoomFormScreen extends ConsumerStatefulWidget { + const RoomFormScreen({super.key, this.roomId}); + + final int? roomId; + + bool get isEditing => roomId != null; + + @override + ConsumerState createState() => _RoomFormScreenState(); +} + +class _RoomFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + String _selectedIconName = curatedRoomIcons.first.name; + bool _isLoading = false; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + if (widget.isEditing) { + _loadRoom(); + } + } + + Future _loadRoom() async { + setState(() => _isLoading = true); + try { + final db = ref.read(appDatabaseProvider); + final room = await db.roomsDao.getRoomById(widget.roomId!); + _nameController.text = room.name; + setState(() { + _selectedIconName = room.iconName; + _isLoading = false; + }); + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Fehler beim Laden: $e')), + ); + } + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _isSaving = true); + + try { + final actions = ref.read(roomActionsProvider.notifier); + if (widget.isEditing) { + final db = ref.read(appDatabaseProvider); + final existing = await db.roomsDao.getRoomById(widget.roomId!); + await actions.updateRoom(existing.copyWith( + name: _nameController.text.trim(), + iconName: _selectedIconName, + )); + } else { + await actions.createRoom( + _nameController.text.trim(), + _selectedIconName, + ); + } + if (mounted) context.pop(); + } catch (e) { + if (mounted) { + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Fehler beim Speichern: $e')), + ); + } + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text( + widget.isEditing ? l10n.roomFormEditTitle : l10n.roomFormCreateTitle, + ), + actions: [ + IconButton( + onPressed: _isSaving ? null : _save, + icon: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Room name field + TextFormField( + controller: _nameController, + autofocus: !widget.isEditing, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + labelText: l10n.roomFormNameLabel, + hintText: l10n.roomFormNameHint, + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return l10n.roomFormNameRequired; + } + if (value.trim().length > 100) { + return 'Maximal 100 Zeichen'; + } + return null; + }, + ), + const SizedBox(height: 24), + // Icon picker preview + Text( + l10n.roomFormIconLabel, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 8), + InkWell( + onTap: () async { + final selected = await showIconPickerSheet( + context: context, + selectedIconName: _selectedIconName, + ); + if (selected != null) { + setState(() => _selectedIconName = selected); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + mapIconName(_selectedIconName), + size: 28, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + l10n.roomFormIconLabel, + style: theme.textTheme.bodyLarge, + ), + ), + Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/rooms/presentation/room_providers.dart b/lib/features/rooms/presentation/room_providers.dart new file mode 100644 index 0000000..c50fa56 --- /dev/null +++ b/lib/features/rooms/presentation/room_providers.dart @@ -0,0 +1,48 @@ +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/rooms/data/rooms_dao.dart'; + +part 'room_providers.g.dart'; + +/// Watches all rooms with computed task stats (due count, cleanliness ratio). +@riverpod +Stream> roomWithStatsList(Ref ref) { + final db = ref.watch(appDatabaseProvider); + return db.roomsDao.watchRoomWithStats(); +} + +/// Async notifier for room mutation actions (create, update, delete, reorder). +@riverpod +class RoomActions extends _$RoomActions { + @override + FutureOr build() {} + + /// Create a new room. Returns the auto-generated id. + Future createRoom(String name, String iconName) async { + final db = ref.read(appDatabaseProvider); + return db.roomsDao.insertRoom(RoomsCompanion.insert( + name: name, + iconName: iconName, + )); + } + + /// Update an existing room. + Future updateRoom(Room room) async { + final db = ref.read(appDatabaseProvider); + await db.roomsDao.updateRoom(room); + } + + /// Delete a room and cascade to its tasks and completions. + Future deleteRoom(int roomId) async { + final db = ref.read(appDatabaseProvider); + await db.roomsDao.deleteRoom(roomId); + } + + /// Reorder rooms by their IDs in the new order. + Future reorderRooms(List roomIds) async { + final db = ref.read(appDatabaseProvider); + await db.roomsDao.reorderRooms(roomIds); + } +} diff --git a/lib/features/rooms/presentation/room_providers.g.dart b/lib/features/rooms/presentation/room_providers.g.dart new file mode 100644 index 0000000..a64c6c2 --- /dev/null +++ b/lib/features/rooms/presentation/room_providers.g.dart @@ -0,0 +1,105 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'room_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Watches all rooms with computed task stats (due count, cleanliness ratio). + +@ProviderFor(roomWithStatsList) +final roomWithStatsListProvider = RoomWithStatsListProvider._(); + +/// Watches all rooms with computed task stats (due count, cleanliness ratio). + +final class RoomWithStatsListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + /// Watches all rooms with computed task stats (due count, cleanliness ratio). + RoomWithStatsListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'roomWithStatsListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$roomWithStatsListHash(); + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + return roomWithStatsList(ref); + } +} + +String _$roomWithStatsListHash() => r'8f6f8d6be77725c38be13e9420609638ec2868f9'; + +/// Async notifier for room mutation actions (create, update, delete, reorder). + +@ProviderFor(RoomActions) +final roomActionsProvider = RoomActionsProvider._(); + +/// Async notifier for room mutation actions (create, update, delete, reorder). +final class RoomActionsProvider + extends $AsyncNotifierProvider { + /// Async notifier for room mutation actions (create, update, delete, reorder). + RoomActionsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'roomActionsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$roomActionsHash(); + + @$internal + @override + RoomActions create() => RoomActions(); +} + +String _$roomActionsHash() => r'4004a7a39474cc4ea1e89b8533edaa217ac543ce'; + +/// Async notifier for room mutation actions (create, update, delete, reorder). + +abstract class _$RoomActions 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/features/tasks/presentation/task_form_screen.dart b/lib/features/tasks/presentation/task_form_screen.dart new file mode 100644 index 0000000..722cd04 --- /dev/null +++ b/lib/features/tasks/presentation/task_form_screen.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.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}); + + final int? roomId; + final int? taskId; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Aufgabe')), + body: const Center(child: Text('Demnächst verfügbar')), + ); + } +} diff --git a/lib/features/tasks/presentation/task_list_screen.dart b/lib/features/tasks/presentation/task_list_screen.dart new file mode 100644 index 0000000..8f05a68 --- /dev/null +++ b/lib/features/tasks/presentation/task_list_screen.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +/// Placeholder for the task list screen within a room. +/// Will be fully implemented in Plan 03. +class TaskListScreen extends StatelessWidget { + const TaskListScreen({super.key, required this.roomId}); + + final int roomId; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Aufgaben')), + body: const Center(child: Text('Demnächst verfügbar')), + ); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8744061..e6fdc55 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -23,5 +23,21 @@ "placeholders": { "version": { "type": "String" } } - } + }, + "roomFormCreateTitle": "Raum erstellen", + "roomFormEditTitle": "Raum bearbeiten", + "roomFormNameLabel": "Raumname", + "roomFormNameHint": "z.B. K\u00fcche, Badezimmer...", + "roomFormNameRequired": "Bitte einen Namen eingeben", + "roomFormIconLabel": "Symbol w\u00e4hlen", + "roomDeleteConfirmTitle": "Raum l\u00f6schen?", + "roomDeleteConfirmMessage": "Der Raum und alle zugeh\u00f6rigen Aufgaben werden unwiderruflich gel\u00f6scht.", + "roomDeleteConfirmAction": "L\u00f6schen", + "roomCardDueCount": "{count} f\u00e4llig", + "@roomCardDueCount": { + "placeholders": { + "count": { "type": "int" } + } + }, + "cancel": "Abbrechen" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7f3cd6c..c1c7990 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -207,6 +207,72 @@ abstract class AppLocalizations { /// In de, this message translates to: /// **'Version {version}'** String aboutVersion(String version); + + /// No description provided for @roomFormCreateTitle. + /// + /// In de, this message translates to: + /// **'Raum erstellen'** + String get roomFormCreateTitle; + + /// No description provided for @roomFormEditTitle. + /// + /// In de, this message translates to: + /// **'Raum bearbeiten'** + String get roomFormEditTitle; + + /// No description provided for @roomFormNameLabel. + /// + /// In de, this message translates to: + /// **'Raumname'** + String get roomFormNameLabel; + + /// No description provided for @roomFormNameHint. + /// + /// In de, this message translates to: + /// **'z.B. Küche, Badezimmer...'** + String get roomFormNameHint; + + /// No description provided for @roomFormNameRequired. + /// + /// In de, this message translates to: + /// **'Bitte einen Namen eingeben'** + String get roomFormNameRequired; + + /// No description provided for @roomFormIconLabel. + /// + /// In de, this message translates to: + /// **'Symbol wählen'** + String get roomFormIconLabel; + + /// No description provided for @roomDeleteConfirmTitle. + /// + /// In de, this message translates to: + /// **'Raum löschen?'** + String get roomDeleteConfirmTitle; + + /// No description provided for @roomDeleteConfirmMessage. + /// + /// In de, this message translates to: + /// **'Der Raum und alle zugehörigen Aufgaben werden unwiderruflich gelöscht.'** + String get roomDeleteConfirmMessage; + + /// No description provided for @roomDeleteConfirmAction. + /// + /// In de, this message translates to: + /// **'Löschen'** + String get roomDeleteConfirmAction; + + /// No description provided for @roomCardDueCount. + /// + /// In de, this message translates to: + /// **'{count} fällig'** + String roomCardDueCount(int count); + + /// No description provided for @cancel. + /// + /// In de, this message translates to: + /// **'Abbrechen'** + String get cancel; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4ca24de..0be0575 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -67,4 +67,40 @@ class AppLocalizationsDe extends AppLocalizations { String aboutVersion(String version) { return 'Version $version'; } + + @override + String get roomFormCreateTitle => 'Raum erstellen'; + + @override + String get roomFormEditTitle => 'Raum bearbeiten'; + + @override + String get roomFormNameLabel => 'Raumname'; + + @override + String get roomFormNameHint => 'z.B. Küche, Badezimmer...'; + + @override + String get roomFormNameRequired => 'Bitte einen Namen eingeben'; + + @override + String get roomFormIconLabel => 'Symbol wählen'; + + @override + String get roomDeleteConfirmTitle => 'Raum löschen?'; + + @override + String get roomDeleteConfirmMessage => + 'Der Raum und alle zugehörigen Aufgaben werden unwiderruflich gelöscht.'; + + @override + String get roomDeleteConfirmAction => 'Löschen'; + + @override + String roomCardDueCount(int count) { + return '$count fällig'; + } + + @override + String get cancel => 'Abbrechen'; } diff --git a/pubspec.yaml b/pubspec.yaml index ca94e5d..70200cf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: go_router: ^17.1.0 path_provider: ^2.1.5 shared_preferences: ^2.5.4 + flutter_reorderable_grid_view: ^5.6.0 dev_dependencies: flutter_test: