feat(01-02): create router, navigation shell, screens, and app shell test
- GoRouter with StatefulShellRoute.indexedStack and 3 branches (Home, Rooms, Settings) - AppShell with NavigationBar using localized labels and thematic icons - HomeScreen with empty state and cross-navigation to Rooms tab - RoomsScreen with empty state and placeholder action button - SettingsScreen with SegmentedButton theme switcher and About section - Widget test verifying 3 navigation destinations with correct German labels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
42
lib/core/router/router.dart
Normal file
42
lib/core/router/router.dart
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/features/home/presentation/home_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/shell/app_shell.dart';
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/',
|
||||||
|
routes: [
|
||||||
|
StatefulShellRoute.indexedStack(
|
||||||
|
builder: (context, state, navigationShell) =>
|
||||||
|
AppShell(navigationShell: navigationShell),
|
||||||
|
branches: [
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
builder: (context, state) => const HomeScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/rooms',
|
||||||
|
builder: (context, state) => const RoomsScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings',
|
||||||
|
builder: (context, state) => const SettingsScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
49
lib/features/home/presentation/home_screen.dart
Normal file
49
lib/features/home/presentation/home_screen.dart
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class HomeScreen extends StatelessWidget {
|
||||||
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.checklist_rounded,
|
||||||
|
size: 80,
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
l10n.homeEmptyTitle,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.homeEmptyMessage,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () => context.go('/rooms'),
|
||||||
|
child: Text(l10n.homeEmptyAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
lib/features/rooms/presentation/rooms_screen.dart
Normal file
50
lib/features/rooms/presentation/rooms_screen.dart
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class RoomsScreen extends StatelessWidget {
|
||||||
|
const RoomsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.door_front_door_rounded,
|
||||||
|
size: 80,
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
l10n.roomsEmptyTitle,
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.roomsEmptyMessage,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () {
|
||||||
|
// Room creation will be implemented in Phase 2
|
||||||
|
},
|
||||||
|
child: Text(l10n.roomsEmptyAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
lib/features/settings/presentation/settings_screen.dart
Normal file
83
lib/features/settings/presentation/settings_screen.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/core/theme/theme_provider.dart';
|
||||||
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class SettingsScreen extends ConsumerWidget {
|
||||||
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final currentThemeMode = ref.watch(themeProvider);
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
children: [
|
||||||
|
// Section 1: Appearance (Darstellung)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
l10n.settingsSectionAppearance,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text(l10n.settingsThemeLabel),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: SegmentedButton<ThemeMode>(
|
||||||
|
segments: [
|
||||||
|
ButtonSegment<ThemeMode>(
|
||||||
|
value: ThemeMode.system,
|
||||||
|
label: Text(l10n.themeSystem),
|
||||||
|
icon: const Icon(Icons.settings_suggest_outlined),
|
||||||
|
),
|
||||||
|
ButtonSegment<ThemeMode>(
|
||||||
|
value: ThemeMode.light,
|
||||||
|
label: Text(l10n.themeLight),
|
||||||
|
icon: const Icon(Icons.light_mode_outlined),
|
||||||
|
),
|
||||||
|
ButtonSegment<ThemeMode>(
|
||||||
|
value: ThemeMode.dark,
|
||||||
|
label: Text(l10n.themeDark),
|
||||||
|
icon: const Icon(Icons.dark_mode_outlined),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {currentThemeMode},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
ref
|
||||||
|
.read(themeProvider.notifier)
|
||||||
|
.setThemeMode(selection.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(indent: 16, endIndent: 16, height: 32),
|
||||||
|
|
||||||
|
// Section 2: About (Ueber)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
l10n.settingsSectionAbout,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text(l10n.aboutAppName),
|
||||||
|
subtitle: Text(l10n.aboutTagline),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Version'),
|
||||||
|
subtitle: Text(l10n.aboutVersion('0.1.0')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
lib/shell/app_shell.dart
Normal file
48
lib/shell/app_shell.dart
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:household_keeper/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class AppShell extends StatelessWidget {
|
||||||
|
const AppShell({
|
||||||
|
required this.navigationShell,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final StatefulNavigationShell navigationShell;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: navigationShell,
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: navigationShell.currentIndex,
|
||||||
|
onDestinationSelected: (index) {
|
||||||
|
navigationShell.goBranch(
|
||||||
|
index,
|
||||||
|
initialLocation: index == navigationShell.currentIndex,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
destinations: [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: const Icon(Icons.checklist_outlined),
|
||||||
|
selectedIcon: const Icon(Icons.checklist),
|
||||||
|
label: l10n.tabHome,
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: const Icon(Icons.door_front_door_outlined),
|
||||||
|
selectedIcon: const Icon(Icons.door_front_door),
|
||||||
|
label: l10n.tabRooms,
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: const Icon(Icons.tune_outlined),
|
||||||
|
selectedIcon: const Icon(Icons.tune),
|
||||||
|
label: l10n.tabSettings,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
test/shell/app_shell_test.dart
Normal file
70
test/shell/app_shell_test.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AppShell Navigation', () {
|
||||||
|
setUp(() {
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify 3 NavigationDestination widgets are rendered
|
||||||
|
expect(find.byType(NavigationDestination), findsNWidgets(3));
|
||||||
|
|
||||||
|
// Verify correct German labels from ARB (with umlauts)
|
||||||
|
expect(find.text('\u00dcbersicht'), findsOneWidget);
|
||||||
|
expect(find.text('R\u00e4ume'), findsOneWidget);
|
||||||
|
expect(find.text('Einstellungen'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.pumpAndSettle();
|
||||||
|
|
||||||
|
// Initially on Home tab (index 0) -- verify home empty state is shown
|
||||||
|
expect(find.text('Noch nichts zu tun!'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap the Rooms tab (second destination)
|
||||||
|
await tester.tap(find.text('R\u00e4ume'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify we see Rooms content now
|
||||||
|
expect(find.text('Hier ist noch alles leer!'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap the Settings tab (third destination)
|
||||||
|
await tester.tap(find.text('Einstellungen'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify we see Settings content now
|
||||||
|
expect(find.text('Darstellung'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user