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