From 3f515045b2f4fe90dd4aa8085b70151535e0bb87 Mon Sep 17 00:00:00 2001 From: Thatsaphorn Atchariyaphap Date: Thu, 25 Sep 2025 17:29:58 +0200 Subject: [PATCH] Feature: Implement AppShell with navigation, add Dashboard, Budget, and Settings views, and integrate GoRouter for routing --- finlog_app/app/lib/app/router.dart | 56 ++-- finlog_app/app/lib/main.dart | 93 ++---- finlog_app/app/lib/modules/app_shell.dart | 266 ++++++++++++++++++ .../app/lib/modules/budget/budget_view.dart | 10 + .../lib/modules/dashboard/dashboard_view.dart | 10 + .../lib/modules/settings/settings_view.dart | 10 + 6 files changed, 356 insertions(+), 89 deletions(-) create mode 100644 finlog_app/app/lib/modules/app_shell.dart create mode 100644 finlog_app/app/lib/modules/budget/budget_view.dart create mode 100644 finlog_app/app/lib/modules/dashboard/dashboard_view.dart create mode 100644 finlog_app/app/lib/modules/settings/settings_view.dart diff --git a/finlog_app/app/lib/app/router.dart b/finlog_app/app/lib/app/router.dart index 222a210..aeff236 100644 --- a/finlog_app/app/lib/app/router.dart +++ b/finlog_app/app/lib/app/router.dart @@ -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/main.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:animations/animations.dart'; -/// Centralized route names and paths. -/// Splash is not routable (handled before router creation). +import '../modules/dashboard/dashboard_view.dart'; +import '../modules/settings/settings_view.dart'; + enum AppRoute { login('/login'), home('/home'), inventory('/inventory'), inventoryAdd('/inventory/add'), budget('/budget'), - expenses('/expenses'); + expenses('/expenses'), + reports('/reports'), + settings('/settings'); const AppRoute(this.path); final String path; } -/// Generic animated page using the `animations` package. -/// Change [transitionType] to vertical / scaled globally if desired. class AnimatedPage extends CustomTransitionPage { AnimatedPage({ required LocalKey super.key, @@ -41,8 +43,6 @@ class AnimatedPage extends CustomTransitionPage { ); } -/// Helper to create a GoRoute with the global AnimatedPage transition. -/// Use this for EVERY route to keep transitions consistent. GoRoute _r( AppRoute route, 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) { return GoRouter( initialLocation: initialRoute.path, routes: [ + // Login separat, ohne Shell _r(AppRoute.login, (ctx, st) => const LoginPage()), - _r(AppRoute.home, (ctx, st) => const MyApp()), - // Add feature routes here, all getting the same animation: - // _r(AppRoute.inventory, (ctx, st) => const InventoryPage(), routes: [ - // _r(AppRoute.inventoryAdd, (ctx, st) => const AddItemPage()), - // ]), - // _r(AppRoute.budget, (ctx, st) => const BudgetPage()), - // _r(AppRoute.expenses, (ctx, st) => const ExpensesPage()), + + // App-Inhalte innerhalb der Shell (AppBar + Drawer bleiben stehen) + ShellRoute( + builder: (context, state, child) => AppShell(child: child), + routes: [ + _r(AppRoute.home, (ctx, st) => const DashboardView()), + _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')), + ), + ], + ), ], ); } diff --git a/finlog_app/app/lib/main.dart b/finlog_app/app/lib/main.dart index 9da0093..b9b6d88 100644 --- a/finlog_app/app/lib/main.dart +++ b/finlog_app/app/lib/main.dart @@ -4,7 +4,7 @@ import 'package:app/app/theme.dart'; import 'package:flutter/material.dart'; import 'package:fluttery/fluttery.dart'; import 'package:fluttery/logger.dart'; -import 'package:fluttery/worker.dart'; +import 'package:go_router/go_router.dart'; Future main() async { // Ensures that the Flutter engine and widget binding @@ -25,87 +25,34 @@ Future main() async { await themeController.init(); runApp( - AnimatedBuilder( - animation: themeController, - builder: (context, _) => MaterialApp.router( - title: 'App', - theme: ThemeData.light(), - darkTheme: ThemeData.dark(), - themeMode: themeController.themeMode, - routerConfig: buildAppRouter(startRoute), - ), + FinlogApp( + router: buildAppRouter(startRoute), + themeController: themeController, ), ); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class FinlogApp extends StatelessWidget { + final GoRouter router; + final ThemeController themeController; - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - App.service().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 createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - App.service().spawn("worker-$_counter", () async { - App.service().info("test"); - - await Future.delayed(const Duration(seconds: 10)); - - App.service().info("end worker"); - }); - setState(() { - _counter++; - }); - } + const FinlogApp({ + super.key, + required this.router, + required this.themeController, + }); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(widget.title), + return AnimatedBuilder( + animation: themeController, + builder: (context, _) => MaterialApp.router( + title: 'Finlog', + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + themeMode: themeController.themeMode, + routerConfig: router, ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - 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. ); } } diff --git a/finlog_app/app/lib/modules/app_shell.dart b/finlog_app/app/lib/modules/app_shell.dart new file mode 100644 index 0000000..16da236 --- /dev/null +++ b/finlog_app/app/lib/modules/app_shell.dart @@ -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 createState() => _AppShellState(); +} + +class _AppShellState extends State { + 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), + ], + ), + ); + } +} diff --git a/finlog_app/app/lib/modules/budget/budget_view.dart b/finlog_app/app/lib/modules/budget/budget_view.dart new file mode 100644 index 0000000..8099359 --- /dev/null +++ b/finlog_app/app/lib/modules/budget/budget_view.dart @@ -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')); + } +} diff --git a/finlog_app/app/lib/modules/dashboard/dashboard_view.dart b/finlog_app/app/lib/modules/dashboard/dashboard_view.dart new file mode 100644 index 0000000..3765c3f --- /dev/null +++ b/finlog_app/app/lib/modules/dashboard/dashboard_view.dart @@ -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')); + } +} diff --git a/finlog_app/app/lib/modules/settings/settings_view.dart b/finlog_app/app/lib/modules/settings/settings_view.dart new file mode 100644 index 0000000..1fba8fb --- /dev/null +++ b/finlog_app/app/lib/modules/settings/settings_view.dart @@ -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')); + } +}