Files
recipe-app/flutter/lib/core/router/app_router.dart
T
Nils-Johan Gynther 69bcc3e342
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 14m6s
Test Suite / flutter-quality (push) Failing after 4m44s
feat(web): improve web build configuration and accessibility
- Add source maps and web renderer build arguments with defaults
- Configure Caddy with CSP headers, cache policies, and service worker handling
- Defer loading of import screen for performance optimization
- Add semantic labels to icons for accessibility
- Update web index.html with Swedish language, meta tags, and description
- Add robots.txt and lighthouse configuration
- Add new planning documents and archive entries
2026-05-23 18:04:27 +02:00

331 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../ui/app_shell.dart';
import '../ui/async_state_views.dart';
import '../../features/auth/data/auth_providers.dart';
import '../../core/auth/jwt_decoder.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/profile/presentation/profile_screen.dart';
import '../../features/recipes/presentation/create_recipe_screen.dart';
import '../../features/recipes/presentation/ai_recipe_suggestions_screen.dart';
import '../../features/recipes/presentation/recipe_detail_screen.dart';
import '../../features/recipes/presentation/recipe_edit_screen.dart';
import '../../features/recipes/presentation/recipes_screen.dart';
import '../../features/inventory/presentation/inventory_screen.dart';
import '../../features/inventory/presentation/inventory_detail_screen.dart';
import '../../features/inventory/presentation/create_inventory_screen.dart';
import '../../features/inventory/presentation/inventory_edit_screen.dart';
import '../../features/inventory/presentation/consume_inventory_screen.dart';
import '../../features/inventory/presentation/consumption_history_screen.dart';
import '../../features/meal_plan/presentation/meal_plan_screen.dart';
import '../../features/pantry/presentation/pantry_screen.dart';
import '../../features/import/presentation/import_screen.dart'
deferred as import_ui;
import '../../features/shopping_list/presentation/shopping_list_screen.dart';
import '../../features/admin/presentation/admin_screen.dart';
int? _shellBranchIndexForPath(String path) {
if (path.startsWith('/recipes')) return 0;
if (path.startsWith('/inventory')) return 1;
if (path.startsWith('/matsedel')) return 2;
if (path.startsWith('/baslager')) return 3;
if (path.startsWith('/import')) return 4;
if (path.startsWith('/inkopslista')) return 5;
if (path.startsWith('/profile')) return 6;
if (path.startsWith('/admin')) return 7;
return null;
}
class _DeferredRouteLoader extends StatelessWidget {
const _DeferredRouteLoader({
required this.loadLibrary,
required this.builder,
});
final Future<void> Function() loadLibrary;
final WidgetBuilder builder;
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: loadLibrary(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Scaffold(
body: LoadingStateView(label: 'Laddar vy...'),
);
}
if (snapshot.hasError) {
return Scaffold(
body: Center(
child: Text('Kunde inte ladda sidan: ${snapshot.error}'),
),
);
}
return builder(context);
},
);
}
}
final appRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authStateProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isLoading = authState.isLoading;
final token = authState.maybeWhen(data: (t) => t, orElse: () => null);
final isLoggedIn = token != null && token.isNotEmpty;
final location = state.matchedLocation;
final isSplash = location == '/';
final isLogin = location == '/login';
if (isLoading) {
return isSplash ? null : '/';
}
if (isSplash) {
return isLoggedIn ? '/recipes' : '/login';
}
if (!isLoggedIn && !isLogin) {
return '/login';
}
if (isLoggedIn && isLogin) {
return '/recipes';
}
return null;
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const Scaffold(
body: LoadingStateView(label: 'Startar...'),
),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
// Detail routes — outside ShellRoute to get full-screen with back button.
// /recipes/create must be listed before /recipes/:id to avoid conflict.
GoRoute(
path: '/recipes/ai-suggestions',
builder: (context, state) => const AiRecipeSuggestionsScreen(),
),
GoRoute(
path: '/recipes/create',
builder: (context, state) {
final extra = state.extra;
String? initialMarkdown;
String? initialImageUrl;
// Use 'is Map' without type params — Dart reifies generics, so
// Map<String,String?> does NOT match Map<String,dynamic> at runtime.
if (extra is Map) {
initialMarkdown = extra['markdown'] as String?;
initialImageUrl = extra['imageUrl'] as String?;
} else if (extra is String) {
// Backwards-compat: plain string means markdown only.
initialMarkdown = extra;
}
return CreateRecipeScreen(
initialMarkdown: initialMarkdown,
initialImageUrl: initialImageUrl,
);
},
),
GoRoute(
path: '/recipes/:id',
redirect: (context, state) {
final raw = state.pathParameters['id'] ?? '';
if (int.tryParse(raw) == null) return '/recipes';
return null;
},
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return RecipeDetailScreen(recipeId: id);
},
),
GoRoute(
path: '/recipes/:id/edit',
redirect: (context, state) {
final raw = state.pathParameters['id'] ?? '';
if (int.tryParse(raw) == null) return '/recipes';
return null;
},
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return RecipeEditScreen(recipeId: id);
},
),
// Inventory detail routes — outside ShellRoute for full-screen.
// /inventory/create must be listed before /inventory/:id.
GoRoute(
path: '/inventory/create',
builder: (context, state) {
final destination = state.uri.queryParameters['destination'];
return CreateInventoryScreen(initialDestination: destination);
},
),
GoRoute(
path: '/inventory/:id',
redirect: (context, state) {
final raw = state.pathParameters['id'] ?? '';
if (int.tryParse(raw) == null) return '/inventory';
return null;
},
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return InventoryDetailScreen(itemId: id);
},
),
GoRoute(
path: '/inventory/:id/edit',
redirect: (context, state) {
final raw = state.pathParameters['id'] ?? '';
if (int.tryParse(raw) == null) return '/inventory';
return null;
},
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return InventoryEditScreen(itemId: id);
},
),
GoRoute(
path: '/inventory/:id/consume',
redirect: (context, state) {
final raw = state.pathParameters['id'] ?? '';
if (int.tryParse(raw) == null) return '/inventory';
return null;
},
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return ConsumeInventoryScreen(itemId: id);
},
),
GoRoute(
path: '/inventory/:id/history',
redirect: (context, state) {
final raw = state.pathParameters['id'] ?? '';
if (int.tryParse(raw) == null) return '/inventory';
return null;
},
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return ConsumptionHistoryScreen(itemId: id);
},
),
// Shell routes — shared AppShell with navigation bar.
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return AppShell(
location: state.uri.path,
onNavigateToPath: (path) {
final index = _shellBranchIndexForPath(path);
if (index == null) {
context.go(path);
return;
}
if (index == navigationShell.currentIndex) {
if (state.uri.path != path) {
context.go(path);
}
return;
}
navigationShell.goBranch(index);
},
child: navigationShell,
);
},
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: '/recipes',
builder: (context, state) => const RecipesScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/inventory',
builder: (context, state) => const InventoryScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/matsedel',
builder: (context, state) => const MealPlanScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/baslager',
builder: (context, state) => const PantryScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/import',
builder: (context, state) => _DeferredRouteLoader(
loadLibrary: import_ui.loadLibrary,
builder: (_) => import_ui.ImportScreen(),
),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/inkopslista',
builder: (context, state) => const ShoppingListScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/admin',
redirect: (context, state) {
final token = ref
.read(authStateProvider)
.maybeWhen(data: (t) => t, orElse: () => null);
return jwtIsAdmin(token) ? null : '/recipes';
},
builder: (context, state) {
final tab = AdminViewTabX.fromQuery(
state.uri.queryParameters['tab'],
);
return AdminScreen(initialTab: tab);
},
),
],
),
],
),
],
);
});