67a7590525
- Add AiTrace model to Prisma schema with relations to User - Implement AiTraceService with CRUD operations for AI traces - Add new admin panel for AI traces with filtering and detail views - Integrate trace persistence in receipt import flow - Add API endpoints for listing and retrieving AI traces - Update Flutter admin UI with new AI tab and navigation - Add new domain models for AI traces and details - Add migration for AiTrace table creation BREAKING CHANGE: None
320 lines
10 KiB
Dart
320 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),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ChoiceChip(
|
|
label: const Text('AI'),
|
|
selected: adminTab == AdminViewTab.ai,
|
|
onSelected: (_) => navigateToAdminTab(AdminViewTab.ai),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|