Files
recipe-app/flutter/lib/core/ui/app_shell.dart
T
Nils-Johan Gynther a1a2c33427
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 5m8s
Test Suite / flutter-quality (push) Failing after 1m41s
feat(shopping-list): add shopping list feature with flyer integration
This commit introduces a comprehensive shopping list feature with the following key changes:

Backend:
- Added ShoppingListItem model with relations to User, Product, and Category
- Added new fields to FlyerSession for source file metadata
- Added categoryId field to FlyerItem model
- Implemented session source file retrieval endpoint
- Added endpoint for updating flyer session items with category assignment
- Added endpoint for planning flyer selections to shopping list
- Implemented backfillCategoriesMine for AI-assisted category assignment
- Added ShoppingListModule and integrated with FlyerSelectionModule

Frontend:
- Added ShoppingListScreen and navigation route
- Implemented API paths and client methods for shopping list operations
- Added category tree loading for shopping list item creation
- Integrated shopping list functionality in flyer import tab
- Added category backfill trigger in inventory screen
- Updated FlyerImportItem model with categoryId support
- Added methods for updating flyer session items and retrieving source files

Database:
- Added new Prisma migration for flyer source metadata and shopping list items
- Updated schema with new relations and indexes

The shopping list feature allows users to:
1. Plan flyer selections directly to their shopping list
2. View and manage their shopping list items
3. Update flyer session items with proper categorization
4. Retrieve original flyer source files
5. Automatically backfill categories for uncategorized products
2026-05-20 09:07:30 +02:00

312 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../features/auth/data/auth_providers.dart';
import '../../features/admin/presentation/admin_screen.dart';
import '../../features/recipes/data/recipes_grid_provider.dart';
const _profileHeaderDestination = _AppDestination(
path: '/profile',
title: 'Profil',
icon: Icons.person,
label: 'Profil',
);
class AppShell extends ConsumerWidget {
final String location;
final ValueChanged<String> onNavigateToPath;
final Widget child;
const AppShell({
super.key,
required this.location,
required this.onNavigateToPath,
required this.child,
});
static const _baseDestinations = [
_AppDestination(
path: '/recipes',
title: 'Recept',
icon: Icons.restaurant_menu,
label: 'Recept',
),
_AppDestination(
path: '/inventory',
title: 'Inventarie',
icon: Icons.inventory_2_outlined,
label: 'Inventarie',
),
_AppDestination(
path: '/matsedel',
title: 'Matsedel',
icon: Icons.calendar_month_outlined,
label: 'Matsedel',
),
_AppDestination(
path: '/baslager',
title: 'Baslager',
icon: Icons.storefront_outlined,
label: 'Baslager',
),
_AppDestination(
path: '/import',
title: 'Importera',
icon: Icons.upload_file_outlined,
label: 'Importera',
),
_AppDestination(
path: '/inkopslista',
title: 'Inköpslista',
icon: Icons.shopping_cart_outlined,
label: 'Inköpslista',
),
];
List<_AppDestination> _destinations() => _baseDestinations;
int? _selectedIndex(List<_AppDestination> destinations) {
final index = destinations.indexWhere(
(destination) => location.startsWith(destination.path),
);
return index < 0 ? null : index;
}
_AppDestination _selectedHeaderDestination(
List<_AppDestination> destinations,
) {
if (location.startsWith('/profile')) {
return _profileHeaderDestination;
}
final selectedIndex = _selectedIndex(destinations);
if (selectedIndex != null) {
return destinations[selectedIndex];
}
return destinations.first;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final locationUri = Uri.parse(location);
final isAdmin = ref.watch(isAdminProvider);
final dests = _destinations();
final selectedIndex = _selectedIndex(dests);
final selectedDestination = _selectedHeaderDestination(dests);
final isWide = MediaQuery.of(context).size.width >= 900;
void navigateTo(int index) {
final target = dests[index].path;
if (target != location && context.mounted) {
onNavigateToPath(target);
}
}
final isRecipesRoute = location.startsWith('/recipes') &&
!location.startsWith('/recipes/');
final isImportRoute = location == '/import';
final isAdminRoute = location.startsWith('/admin');
final adminTab = AdminViewTabX.fromQuery(
locationUri.queryParameters['tab'],
);
void navigateToAdminTab(AdminViewTab tab) {
final target = '/admin?tab=${tab.queryValue}';
if (target != location && context.mounted) {
onNavigateToPath(target);
}
}
Widget buildAdminTitle() {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ChoiceChip(
label: const Text('Användare'),
selected: adminTab == AdminViewTab.users,
onSelected: (_) => navigateToAdminTab(AdminViewTab.users),
),
const SizedBox(width: 8),
ChoiceChip(
label: const Text('Databas'),
selected: adminTab == AdminViewTab.database,
onSelected: (_) => navigateToAdminTab(AdminViewTab.database),
),
],
),
);
}
Widget shell = Scaffold(
appBar: AppBar(
title: isAdminRoute ? buildAdminTitle() : Text(selectedDestination.title),
bottom: isImportRoute
? const TabBar(
tabs: [
Tab(
icon: Icon(Icons.restaurant_menu_outlined),
text: 'Recept',
),
Tab(
icon: Icon(Icons.receipt_long_outlined),
text: 'Kvitto',
),
Tab(
icon: Icon(Icons.local_offer_outlined),
text: 'Flyer',
),
],
)
: null,
actions: [
if (isRecipesRoute)
Consumer(
builder: (context, ref, child) {
final view = ref.watch(recipesViewProvider).maybeWhen(
data: (v) => v,
orElse: () => (mode: RecipesViewMode.grid, columns: 2),
);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: view.mode == RecipesViewMode.grid
? 'Visa som lista'
: 'Visa som grid',
icon: Icon(view.mode == RecipesViewMode.grid
? Icons.view_list
: Icons.grid_view),
onPressed: () =>
ref.read(recipesViewProvider.notifier).toggleMode(),
),
if (view.mode == RecipesViewMode.grid)
PopupMenuButton<int>(
icon: const Icon(Icons.grid_view),
tooltip: 'Välj antal kolumner',
onSelected: (columns) =>
ref.read(recipesViewProvider.notifier).setColumns(columns),
itemBuilder: (context) => const [
PopupMenuItem(value: 2, child: Text('2 kolumner')),
PopupMenuItem(value: 4, child: Text('4 kolumner')),
PopupMenuItem(value: 6, child: Text('6 kolumner')),
PopupMenuItem(value: 8, child: Text('8 kolumner')),
],
),
],
);
},
),
PopupMenuButton<String>(
tooltip: 'Profil och konto',
icon: const Icon(Icons.account_circle_outlined),
onSelected: (value) async {
switch (value) {
case 'profile':
if (location != '/profile' && context.mounted) {
onNavigateToPath('/profile');
}
break;
case 'admin':
if (location != '/admin' && context.mounted) {
onNavigateToPath('/admin');
}
break;
case 'logout':
await ref.read(authStateProvider.notifier).logout();
if (context.mounted) {
onNavigateToPath('/login');
}
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'profile',
child: ListTile(
leading: Icon(Icons.person_outline),
title: Text('Profil'),
contentPadding: EdgeInsets.zero,
),
),
if (isAdmin)
const PopupMenuItem<String>(
value: 'admin',
child: ListTile(
leading: Icon(Icons.admin_panel_settings_outlined),
title: Text('Admin'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuDivider(),
const PopupMenuItem<String>(
value: 'logout',
child: ListTile(
leading: Icon(Icons.logout),
title: Text('Logga ut'),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
body: isWide
? Row(
children: [
NavigationRail(
selectedIndex: selectedIndex ?? 0,
onDestinationSelected: navigateTo,
labelType: NavigationRailLabelType.all,
destinations: dests
.map(
(destination) => NavigationRailDestination(
icon: Icon(destination.icon),
label: Text(destination.label),
),
)
.toList(),
),
const VerticalDivider(width: 1),
Expanded(child: child),
],
)
: child,
bottomNavigationBar: isWide
? null
: NavigationBar(
selectedIndex: selectedIndex ?? 0,
onDestinationSelected: navigateTo,
destinations: dests
.map(
(destination) => NavigationDestination(
icon: Icon(destination.icon),
label: destination.label,
),
)
.toList(),
),
);
if (isImportRoute) {
shell = DefaultTabController(length: 3, child: shell);
}
return shell;
}
}
class _AppDestination {
final String path;
final String title;
final IconData icon;
final String label;
const _AppDestination({
required this.path,
required this.title,
required this.icon,
required this.label,
});
}