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 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 22:07:09 +01:00
parent 32e61e4bec
commit 519a56bef7
3 changed files with 290 additions and 25 deletions

View File

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

View File

@@ -1,13 +1,64 @@
import 'package:flutter/material.dart'; 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'; 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}); const RoomsScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context); 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); final theme = Theme.of(context);
return Center( return Center(
@@ -37,9 +88,7 @@ class RoomsScreen extends StatelessWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
FilledButton.tonal( FilledButton.tonal(
onPressed: () { onPressed: () => context.go('/rooms/new'),
// Room creation will be implemented in Phase 2
},
child: Text(l10n.roomsEmptyAction), 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<RoomWithStats> 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<Widget>(
scrollController: _scrollController,
onReorder: (ReorderedListFunction<Widget> reorderFunc) {
final reordered = reorderFunc(children);
final newOrder = reordered
.map((w) => (w.key! as ValueKey<int>).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),
),
],
),
);
}
}

View File

@@ -1,9 +1,11 @@
// ignore_for_file: scoped_providers_should_specify_dependencies
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:household_keeper/core/router/router.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'; import 'package:household_keeper/l10n/app_localizations.dart';
void main() { void main() {
@@ -12,18 +14,29 @@ void main() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
testWidgets('renders 3 navigation destinations with correct German labels', /// Helper to build the app with room provider overridden to empty list.
(tester) async { Widget buildApp() {
await tester.pumpWidget( return ProviderScope(
ProviderScope( overrides: [
child: MaterialApp.router( // Override the stream provider to return an empty list immediately
routerConfig: router, // so that the rooms screen shows the empty state without needing a DB.
localizationsDelegates: AppLocalizations.localizationsDelegates, // ignore: scoped_providers_should_specify_dependencies
supportedLocales: const [Locale('de')], roomWithStatsListProvider.overrideWith(
locale: const Locale('de'), (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(); await tester.pumpAndSettle();
// Verify 3 NavigationDestination widgets are rendered // Verify 3 NavigationDestination widgets are rendered
@@ -37,16 +50,7 @@ void main() {
testWidgets('tapping a destination changes the selected tab', testWidgets('tapping a destination changes the selected tab',
(tester) async { (tester) async {
await tester.pumpWidget( await tester.pumpWidget(buildApp());
ProviderScope(
child: MaterialApp.router(
routerConfig: router,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: const [Locale('de')],
locale: const Locale('de'),
),
),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Initially on Home tab (index 0) -- verify home empty state is shown // 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.tap(find.text('R\u00e4ume'));
await tester.pumpAndSettle(); 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); expect(find.text('Hier ist noch alles leer!'), findsOneWidget);
// Tap the Settings tab (third destination) // Tap the Settings tab (third destination)