Feature: Implement AppShell with navigation, add Dashboard, Budget, and Settings views, and integrate GoRouter for routing

This commit is contained in:
2025-09-25 17:29:58 +02:00
parent 25e07aef1e
commit 3f515045b2
6 changed files with 356 additions and 89 deletions

View File

@@ -1,26 +1,28 @@
import 'package:app/modules/app_shell.dart';
import 'package:app/modules/budget/budget_view.dart';
import 'package:app/modules/login/pages/login_page.dart'; import 'package:app/modules/login/pages/login_page.dart';
import 'package:app/main.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:animations/animations.dart'; import 'package:animations/animations.dart';
/// Centralized route names and paths. import '../modules/dashboard/dashboard_view.dart';
/// Splash is not routable (handled before router creation). import '../modules/settings/settings_view.dart';
enum AppRoute { enum AppRoute {
login('/login'), login('/login'),
home('/home'), home('/home'),
inventory('/inventory'), inventory('/inventory'),
inventoryAdd('/inventory/add'), inventoryAdd('/inventory/add'),
budget('/budget'), budget('/budget'),
expenses('/expenses'); expenses('/expenses'),
reports('/reports'),
settings('/settings');
const AppRoute(this.path); const AppRoute(this.path);
final String path; final String path;
} }
/// Generic animated page using the `animations` package.
/// Change [transitionType] to vertical / scaled globally if desired.
class AnimatedPage<T> extends CustomTransitionPage<T> { class AnimatedPage<T> extends CustomTransitionPage<T> {
AnimatedPage({ AnimatedPage({
required LocalKey super.key, required LocalKey super.key,
@@ -41,8 +43,6 @@ class AnimatedPage<T> extends CustomTransitionPage<T> {
); );
} }
/// Helper to create a GoRoute with the global AnimatedPage transition.
/// Use this for EVERY route to keep transitions consistent.
GoRoute _r( GoRoute _r(
AppRoute route, AppRoute route,
Widget Function(BuildContext, GoRouterState) builder, { Widget Function(BuildContext, GoRouterState) builder, {
@@ -61,19 +61,43 @@ GoRoute _r(
); );
} }
/// Builds the application-wide router. The initial route is decided before creation.
GoRouter buildAppRouter(AppRoute initialRoute) { GoRouter buildAppRouter(AppRoute initialRoute) {
return GoRouter( return GoRouter(
initialLocation: initialRoute.path, initialLocation: initialRoute.path,
routes: [ routes: [
// Login separat, ohne Shell
_r(AppRoute.login, (ctx, st) => const LoginPage()), _r(AppRoute.login, (ctx, st) => const LoginPage()),
_r(AppRoute.home, (ctx, st) => const MyApp()),
// Add feature routes here, all getting the same animation: // App-Inhalte innerhalb der Shell (AppBar + Drawer bleiben stehen)
// _r(AppRoute.inventory, (ctx, st) => const InventoryPage(), routes: [ ShellRoute(
// _r(AppRoute.inventoryAdd, (ctx, st) => const AddItemPage()), builder: (context, state, child) => AppShell(child: child),
// ]), routes: [
// _r(AppRoute.budget, (ctx, st) => const BudgetPage()), _r(AppRoute.home, (ctx, st) => const DashboardView()),
// _r(AppRoute.expenses, (ctx, st) => const ExpensesPage()), _r(AppRoute.budget, (ctx, st) => const BudgetView()),
_r(AppRoute.settings, (ctx, st) => const SettingsView()),
// Stubs aktualisiere hier, wenn deine Seiten fertig sind:
_r(
AppRoute.inventory,
(ctx, st) => const Center(child: Text('Inventar')),
routes: [
_r(
AppRoute.inventoryAdd,
(ctx, st) => const Center(child: Text('Inventar: Hinzufügen')),
),
],
),
_r(
AppRoute.expenses,
(ctx, st) => const Center(child: Text('Ausgaben')),
),
_r(
AppRoute.reports,
(ctx, st) =>
const Center(child: Text('Reports Auswertungen & Diagramme')),
),
],
),
], ],
); );
} }

View File

@@ -4,7 +4,7 @@ import 'package:app/app/theme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttery/fluttery.dart'; import 'package:fluttery/fluttery.dart';
import 'package:fluttery/logger.dart'; import 'package:fluttery/logger.dart';
import 'package:fluttery/worker.dart'; import 'package:go_router/go_router.dart';
Future<void> main() async { Future<void> main() async {
// Ensures that the Flutter engine and widget binding // Ensures that the Flutter engine and widget binding
@@ -25,87 +25,34 @@ Future<void> main() async {
await themeController.init(); await themeController.init();
runApp( runApp(
AnimatedBuilder( FinlogApp(
router: buildAppRouter(startRoute),
themeController: themeController,
),
);
}
class FinlogApp extends StatelessWidget {
final GoRouter router;
final ThemeController themeController;
const FinlogApp({
super.key,
required this.router,
required this.themeController,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: themeController, animation: themeController,
builder: (context, _) => MaterialApp.router( builder: (context, _) => MaterialApp.router(
title: 'App', title: 'Finlog',
theme: ThemeData.light(), theme: ThemeData.light(),
darkTheme: ThemeData.dark(), darkTheme: ThemeData.dark(),
themeMode: themeController.themeMode, themeMode: themeController.themeMode,
routerConfig: buildAppRouter(startRoute), routerConfig: router,
), ),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
App.service<Logger>().info("test");
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
App.service<Worker>().spawn("worker-$_counter", () async {
App.service<Logger>().info("test");
await Future.delayed(const Duration(seconds: 10));
App.service<Logger>().info("end worker");
});
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
TextButton(onPressed: () {}, child: Text("Print workers")),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
); );
} }
} }

View File

@@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class AppShell extends StatefulWidget {
final Widget child;
const AppShell({super.key, required this.child});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
static const double _railBreakpoint = 800; // tablet and up
bool _railExtended = true; // start "open" on tablet/desktop
final FocusNode _contentFocus = FocusNode(debugLabel: 'contentFocus');
@override
void dispose() {
_contentFocus.dispose();
super.dispose();
}
// --- NAV ITEMS -------------------------------------------------------------
final _items =
const <
({IconData icon, IconData? selectedIcon, String label, String route})
>[
(
icon: Icons.dashboard_outlined,
selectedIcon: Icons.dashboard,
label: 'Dashboard',
route: '/home',
),
(
icon: Icons.account_balance_wallet_outlined,
selectedIcon: Icons.account_balance_wallet,
label: 'Budgets',
route: '/budget',
),
(
icon: Icons.inventory_2_outlined,
selectedIcon: Icons.inventory_2,
label: 'Inventar',
route: '/inventory',
),
(
icon: Icons.bar_chart_outlined,
selectedIcon: Icons.bar_chart,
label: 'Reports',
route: '/reports',
),
(
icon: Icons.settings,
selectedIcon: Icons.settings_outlined,
label: 'Settings',
route: '/settings',
),
];
int _indexForPath(String p) {
for (var i = 0; i < _items.length; i++) {
if (p.startsWith(_items[i].route)) return i;
}
return 0;
}
void _goForIndex(BuildContext ctx, int i) => ctx.go(_items[i].route);
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isRail = width >= _railBreakpoint;
final currentPath = GoRouterState.of(context).matchedLocation;
// keep focus on right/content pane on wide layouts
if (isRail) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_contentFocus.hasFocus) _contentFocus.requestFocus();
});
}
final appBar = AppBar(
title: _LogoHeader(),
leading: isRail
? null
: Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(ctx).openDrawer(),
tooltip: 'Menü',
),
),
actions: [
IconButton(
tooltip: 'Benachrichtigungen',
onPressed: () {},
icon: const Icon(Icons.notifications_none),
),
IconButton(
tooltip: 'Benutzer-Einstellungen',
onPressed: () => context.push('/settings'),
icon: const Icon(Icons.account_circle_outlined),
),
],
);
if (!isRail) {
// ------------------- MOBILE: Drawer -------------------
final selectedIndex = _indexForPath(currentPath);
return Scaffold(
appBar: appBar,
drawer: _AppDrawer(items: _items, selectedIndex: selectedIndex),
body: SafeArea(child: widget.child),
);
}
// ------------------- TABLET/DESKTOP: NavigationRail -------------------
final selected = _indexForPath(currentPath);
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: appBar,
body: Row(
children: [
NavigationRailTheme(
data: NavigationRailThemeData(
groupAlignment: -1.0,
// align to top
useIndicator: true,
indicatorColor: scheme.secondaryContainer,
// background for selected "button"
indicatorShape: const StadiumBorder(),
selectedIconTheme: IconThemeData(
color: scheme.onSecondaryContainer,
),
selectedLabelTextStyle: TextStyle(
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
child: NavigationRail(
extended: _railExtended,
selectedIndex: selected,
onDestinationSelected: (i) => _goForIndex(context, i),
// leading: const Padding(
// padding: EdgeInsets.only(top: 8),
// child: _LogoHeader(),
// ),
destinations: [
for (final it in _items)
NavigationRailDestination(
icon: Icon(it.icon),
selectedIcon: Icon(it.selectedIcon ?? it.icon),
label: Text(it.label),
),
],
trailingAtBottom: true,
trailing: Column(
children: [
const SizedBox(height: 8),
const Divider(height: 1),
// toggle lives at the very bottom so layout doesn't jump
IconButton(
tooltip: _railExtended
? 'Leiste verkleinern'
: 'Leiste erweitern',
onPressed: () =>
setState(() => _railExtended = !_railExtended),
icon: Icon(
_railExtended
? Icons.keyboard_double_arrow_left
: Icons.keyboard_double_arrow_right,
),
),
],
),
),
),
const VerticalDivider(width: 1),
Expanded(
child: SafeArea(
child: Focus(
focusNode: _contentFocus,
autofocus: true,
child: widget.child,
),
),
),
],
),
);
}
}
class _AppDrawer extends StatelessWidget {
final List<
({IconData icon, IconData? selectedIcon, String label, String route})
>
items;
final int selectedIndex;
const _AppDrawer({required this.items, required this.selectedIndex});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Drawer(
child: SafeArea(
child: ListTileTheme(
selectedColor: scheme.onSecondaryContainer,
selectedTileColor: scheme.secondaryContainer,
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
const _LogoHeader(),
const Divider(),
for (var i = 0; i < items.length; i++)
ListTile(
selected: i == selectedIndex,
leading: Icon(
i == selectedIndex
? (items[i].selectedIcon ?? items[i].icon)
: items[i].icon,
),
title: Text(items[i].label),
onTap: () {
Navigator.of(context).maybePop();
context.go(items[i].route);
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('Einstellungen'),
onTap: () {
Navigator.of(context).maybePop();
context.go('/settings');
},
),
],
),
),
),
);
}
}
class _LogoHeader extends StatelessWidget {
const _LogoHeader();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.account_balance_wallet, size: 28),
SizedBox(width: 8),
],
),
);
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class BudgetView extends StatelessWidget {
const BudgetView({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: Text('Budgets'));
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class DashboardView extends StatelessWidget {
const DashboardView({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: Text('Dashboard Willkommen bei Finlog'));
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class SettingsView extends StatelessWidget {
const SettingsView({super.key});
@override
Widget build(BuildContext context) {
return const Center(child: Text('Settings'));
}
}