Feature: Implement AppShell with navigation, add Dashboard, Budget, and Settings views, and integrate GoRouter for routing
This commit is contained in:
@@ -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')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
266
finlog_app/app/lib/modules/app_shell.dart
Normal file
266
finlog_app/app/lib/modules/app_shell.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
finlog_app/app/lib/modules/budget/budget_view.dart
Normal file
10
finlog_app/app/lib/modules/budget/budget_view.dart
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
finlog_app/app/lib/modules/dashboard/dashboard_view.dart
Normal file
10
finlog_app/app/lib/modules/dashboard/dashboard_view.dart
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
finlog_app/app/lib/modules/settings/settings_view.dart
Normal file
10
finlog_app/app/lib/modules/settings/settings_view.dart
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user