feat(02-02): create room providers, form screen, icon picker, and router routes

- Add Riverpod providers (roomWithStatsList, RoomActions) connecting to RoomsDao
- Create RoomFormScreen with name field, icon picker preview, create/edit modes
- Create IconPickerSheet bottom sheet with curated Material Icons grid
- Add nested GoRouter routes: /rooms/new, /rooms/:roomId, /rooms/:roomId/edit
- Add placeholder TaskListScreen and TaskFormScreen for Plan 03 routes
- Add 11 new German localization keys for room management UI
- Add flutter_reorderable_grid_view dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 22:00:57 +01:00
parent ead53b4c02
commit 32e61e4bec
11 changed files with 652 additions and 1 deletions

View File

@@ -1,8 +1,11 @@
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:household_keeper/features/home/presentation/home_screen.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/rooms/presentation/rooms_screen.dart';
import 'package:household_keeper/features/settings/presentation/settings_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'; import 'package:household_keeper/shell/app_shell.dart';
final router = GoRouter( final router = GoRouter(
@@ -25,6 +28,46 @@ final router = GoRouter(
GoRoute( GoRoute(
path: '/rooms', path: '/rooms',
builder: (context, state) => const RoomsScreen(), 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);
},
),
],
),
],
), ),
], ],
), ),

View File

@@ -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<String?> showIconPickerSheet({
required BuildContext context,
String? selectedIconName,
}) {
return showModalBottomSheet<String>(
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<String> 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),
],
),
),
);
}
}

View File

@@ -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<RoomFormScreen> createState() => _RoomFormScreenState();
}
class _RoomFormScreenState extends ConsumerState<RoomFormScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
String _selectedIconName = curatedRoomIcons.first.name;
bool _isLoading = false;
bool _isSaving = false;
@override
void initState() {
super.initState();
if (widget.isEditing) {
_loadRoom();
}
}
Future<void> _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<void> _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,
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -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<List<RoomWithStats>> 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<void> build() {}
/// Create a new room. Returns the auto-generated id.
Future<int> 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<void> 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<void> deleteRoom(int roomId) async {
final db = ref.read(appDatabaseProvider);
await db.roomsDao.deleteRoom(roomId);
}
/// Reorder rooms by their IDs in the new order.
Future<void> reorderRooms(List<int> roomIds) async {
final db = ref.read(appDatabaseProvider);
await db.roomsDao.reorderRooms(roomIds);
}
}

View File

@@ -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<RoomWithStats>>,
List<RoomWithStats>,
Stream<List<RoomWithStats>>
>
with
$FutureModifier<List<RoomWithStats>>,
$StreamProvider<List<RoomWithStats>> {
/// 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<List<RoomWithStats>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<RoomWithStats>> 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<RoomActions, void> {
/// 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<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

@@ -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')),
);
}
}

View File

@@ -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')),
);
}
}

View File

@@ -23,5 +23,21 @@
"placeholders": { "placeholders": {
"version": { "type": "String" } "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"
} }

View File

@@ -207,6 +207,72 @@ abstract class AppLocalizations {
/// In de, this message translates to: /// In de, this message translates to:
/// **'Version {version}'** /// **'Version {version}'**
String aboutVersion(String 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 class _AppLocalizationsDelegate

View File

@@ -67,4 +67,40 @@ class AppLocalizationsDe extends AppLocalizations {
String aboutVersion(String version) { String aboutVersion(String version) {
return 'Version $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';
} }

View File

@@ -19,6 +19,7 @@ dependencies:
go_router: ^17.1.0 go_router: ^17.1.0
path_provider: ^2.1.5 path_provider: ^2.1.5
shared_preferences: ^2.5.4 shared_preferences: ^2.5.4
flutter_reorderable_grid_view: ^5.6.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: