From 519a56bef786e1e4fdc76baa9f77afffab1d812e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 22:07:09 +0100 Subject: [PATCH] feat(02-02): build rooms screen with reorderable card grid and room card - Replace placeholder RoomsScreen with ConsumerWidget watching roomWithStatsProvider - Create RoomCard with icon, name, due count badge, cleanliness progress bar - 2-column ReorderableBuilder grid with drag-and-drop reorder - Empty state, loading, error states with retry - Long-press menu for edit/delete with confirmation dialog - FAB for room creation navigation - Update app_shell_test with provider override for rooms stream Co-Authored-By: Claude Opus 4.6 --- .../rooms/presentation/room_card.dart | 123 +++++++++++++++ .../rooms/presentation/rooms_screen.dart | 148 +++++++++++++++++- test/shell/app_shell_test.dart | 44 +++--- 3 files changed, 290 insertions(+), 25 deletions(-) create mode 100644 lib/features/rooms/presentation/room_card.dart diff --git a/lib/features/rooms/presentation/room_card.dart b/lib/features/rooms/presentation/room_card.dart new file mode 100644 index 0000000..601c478 --- /dev/null +++ b/lib/features/rooms/presentation/room_card.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:household_keeper/features/rooms/data/rooms_dao.dart'; +import 'package:household_keeper/features/rooms/domain/room_icons.dart'; +import 'package:household_keeper/l10n/app_localizations.dart'; + +/// A card widget displaying room info: icon, name, due task count, and +/// a thin cleanliness progress bar at the bottom. +class RoomCard extends StatelessWidget { + const RoomCard({ + super.key, + required this.roomWithStats, + this.onEdit, + this.onDelete, + }); + + final RoomWithStats roomWithStats; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context); + final room = roomWithStats.room; + + // Green -> Red based on cleanliness ratio + const cleanColor = Color(0xFF7A9A6D); // Sage green (clean) + const dirtyColor = Color(0xFFE07A5F); // Warm coral (overdue) + final barColor = Color.lerp( + dirtyColor, + cleanColor, + roomWithStats.cleanlinessRatio, + )!; + + return Card( + color: theme.colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0.5, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.go('/rooms/${room.id}'), + onLongPress: () => _showMenu(context, l10n), + child: Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + mapIconName(room.iconName), + size: 36, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 8), + Text( + room.name, + style: theme.textTheme.titleSmall, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (roomWithStats.dueTasks > 0) ...[ + const SizedBox(height: 4), + Text( + l10n.roomCardDueCount(roomWithStats.dueTasks), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ), + ), + ), + // Thin cleanliness bar at bottom + LinearProgressIndicator( + value: roomWithStats.cleanlinessRatio, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + color: barColor, + minHeight: 3, + ), + ], + ), + ), + ); + } + + void _showMenu(BuildContext context, AppLocalizations l10n) { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.edit), + title: Text(l10n.roomFormEditTitle), + onTap: () { + Navigator.pop(context); + onEdit?.call(); + }, + ), + ListTile( + leading: Icon(Icons.delete, color: Theme.of(context).colorScheme.error), + title: Text( + l10n.roomDeleteConfirmAction, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + onTap: () { + Navigator.pop(context); + onDelete?.call(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/rooms/presentation/rooms_screen.dart b/lib/features/rooms/presentation/rooms_screen.dart index ec33f46..0227d2e 100644 --- a/lib/features/rooms/presentation/rooms_screen.dart +++ b/lib/features/rooms/presentation/rooms_screen.dart @@ -1,13 +1,64 @@ import 'package:flutter/material.dart'; +import 'package:flutter_reorderable_grid_view/widgets/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:household_keeper/features/rooms/data/rooms_dao.dart'; +import 'package:household_keeper/features/rooms/presentation/room_card.dart'; +import 'package:household_keeper/features/rooms/presentation/room_providers.dart'; import 'package:household_keeper/l10n/app_localizations.dart'; -class RoomsScreen extends StatelessWidget { +/// Rooms screen showing a 2-column reorderable card grid of rooms. +/// +/// Displays an empty state when no rooms exist, with a button to create one. +/// FAB always visible for quick room creation. +class RoomsScreen extends ConsumerWidget { const RoomsScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); + final asyncRooms = ref.watch(roomWithStatsListProvider); + + return Scaffold( + body: asyncRooms.when( + data: (rooms) { + if (rooms.isEmpty) { + return _EmptyState(l10n: l10n); + } + return _RoomGrid(rooms: rooms); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Fehler: $error'), + const SizedBox(height: 16), + FilledButton.tonal( + onPressed: () => ref.invalidate(roomWithStatsListProvider), + child: const Text('Erneut versuchen'), + ), + ], + ), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.go('/rooms/new'), + child: const Icon(Icons.add), + ), + ); + } +} + +/// Empty state shown when no rooms have been created yet. +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.l10n}); + + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { final theme = Theme.of(context); return Center( @@ -37,9 +88,7 @@ class RoomsScreen extends StatelessWidget { ), const SizedBox(height: 24), FilledButton.tonal( - onPressed: () { - // Room creation will be implemented in Phase 2 - }, + onPressed: () => context.go('/rooms/new'), child: Text(l10n.roomsEmptyAction), ), ], @@ -48,3 +97,92 @@ class RoomsScreen extends StatelessWidget { ); } } + +/// 2-column reorderable grid of room cards. +class _RoomGrid extends ConsumerStatefulWidget { + const _RoomGrid({required this.rooms}); + + final List rooms; + + @override + ConsumerState<_RoomGrid> createState() => _RoomGridState(); +} + +class _RoomGridState extends ConsumerState<_RoomGrid> { + final _scrollController = ScrollController(); + final _gridViewKey = GlobalKey(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final children = widget.rooms.map((rws) { + return RoomCard( + key: ValueKey(rws.room.id), + roomWithStats: rws, + onEdit: () => context.go('/rooms/${rws.room.id}/edit'), + onDelete: () => _showDeleteConfirmation(context, rws, l10n), + ); + }).toList(); + + return ReorderableBuilder( + scrollController: _scrollController, + onReorder: (ReorderedListFunction reorderFunc) { + final reordered = reorderFunc(children); + final newOrder = reordered + .map((w) => (w.key! as ValueKey).value) + .toList(); + ref.read(roomActionsProvider.notifier).reorderRooms(newOrder); + }, + builder: (reorderableChildren) { + return GridView.count( + key: _gridViewKey, + controller: _scrollController, + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.0, + children: reorderableChildren, + ); + }, + children: children, + ); + } + + void _showDeleteConfirmation( + BuildContext context, + RoomWithStats rws, + AppLocalizations l10n, + ) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.roomDeleteConfirmTitle), + content: Text(l10n.roomDeleteConfirmMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(ctx); + ref.read(roomActionsProvider.notifier).deleteRoom(rws.room.id); + }, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + child: Text(l10n.roomDeleteConfirmAction), + ), + ], + ), + ); + } +} diff --git a/test/shell/app_shell_test.dart b/test/shell/app_shell_test.dart index 4dc2a0c..9665d65 100644 --- a/test/shell/app_shell_test.dart +++ b/test/shell/app_shell_test.dart @@ -1,9 +1,11 @@ +// ignore_for_file: scoped_providers_should_specify_dependencies import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:household_keeper/core/router/router.dart'; +import 'package:household_keeper/features/rooms/presentation/room_providers.dart'; import 'package:household_keeper/l10n/app_localizations.dart'; void main() { @@ -12,18 +14,29 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - testWidgets('renders 3 navigation destinations with correct German labels', - (tester) async { - await tester.pumpWidget( - ProviderScope( - child: MaterialApp.router( - routerConfig: router, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: const [Locale('de')], - locale: const Locale('de'), + /// Helper to build the app with room provider overridden to empty list. + Widget buildApp() { + return ProviderScope( + 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([]), ), + ], + child: MaterialApp.router( + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: const [Locale('de')], + locale: const Locale('de'), ), ); + } + + testWidgets('renders 3 navigation destinations with correct German labels', + (tester) async { + await tester.pumpWidget(buildApp()); await tester.pumpAndSettle(); // Verify 3 NavigationDestination widgets are rendered @@ -37,16 +50,7 @@ void main() { testWidgets('tapping a destination changes the selected tab', (tester) async { - await tester.pumpWidget( - ProviderScope( - child: MaterialApp.router( - routerConfig: router, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: const [Locale('de')], - locale: const Locale('de'), - ), - ), - ); + await tester.pumpWidget(buildApp()); await tester.pumpAndSettle(); // Initially on Home tab (index 0) -- verify home empty state is shown @@ -56,7 +60,7 @@ void main() { await tester.tap(find.text('R\u00e4ume')); await tester.pumpAndSettle(); - // Verify we see Rooms content now + // Verify we see Rooms content now (empty state) expect(find.text('Hier ist noch alles leer!'), findsOneWidget); // Tap the Settings tab (third destination)