feat: Add functionality to move inventory items to pantry and enhance pantry management
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- Implemented moveInventoryItemToPantry method in InventoryRepository to facilitate moving items from inventory to pantry. - Enhanced InventoryScreen with a new header section providing context about the inventory. - Added a button in SwipeableInventoryTile to move items to pantry with appropriate error handling. - Introduced movePantryItemToInventory method in PantryRepository to support moving items back to inventory. - Refactored PantryScreen to rename _addToInventory to _moveToInventory for clarity and updated UI to reflect changes. - Added AdminPantryItem model to represent pantry items in the admin panel. - Created AdminPantryPanel for managing pantry items, including moving items to inventory and listing users. - Developed AdminPrivateProductsPanel for managing private products, allowing promotion to global products.
This commit is contained in:
@@ -9,8 +9,6 @@ import '../data/profile_repository.dart';
|
||||
import '../domain/user_profile.dart';
|
||||
import 'user_aliases_screen.dart';
|
||||
|
||||
enum _ProfileTab { profile }
|
||||
|
||||
class ProfileScreen extends ConsumerStatefulWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@@ -24,7 +22,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
bool _isSaving = false;
|
||||
String? _error;
|
||||
UserProfile? _profile;
|
||||
_ProfileTab _activeTab = _ProfileTab.profile;
|
||||
|
||||
late final TextEditingController _emailCtrl;
|
||||
late final TextEditingController _firstNameCtrl;
|
||||
@@ -99,40 +96,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
context.go('/login');
|
||||
}
|
||||
|
||||
|
||||
List<_ProfileTab> _visibleTabs(bool isAdmin) {
|
||||
return [
|
||||
_ProfileTab.profile,
|
||||
];
|
||||
}
|
||||
|
||||
String _tabLabel(_ProfileTab tab) {
|
||||
switch (tab) {
|
||||
case _ProfileTab.profile:
|
||||
return context.l10n.profileMyProfileTab;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTabBar(BuildContext context, List<_ProfileTab> tabs) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: tabs
|
||||
.map(
|
||||
(tab) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(_tabLabel(tab)),
|
||||
selected: _activeTab == tab,
|
||||
onSelected: (_) => setState(() => _activeTab = tab),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileForm(BuildContext context, ThemeData theme) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
@@ -182,15 +145,16 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
child: _isSaving
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(context.l10n.profileSaveAction),
|
||||
: const Icon(Icons.save_outlined),
|
||||
label: Text(context.l10n.profileSaveAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -198,103 +162,132 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActiveTabContent(BuildContext context, ThemeData theme) {
|
||||
switch (_activeTab) {
|
||||
case _ProfileTab.profile:
|
||||
return _buildProfileForm(context, theme);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tabs = _visibleTabs(_profile?.isAdmin == true);
|
||||
if (!tabs.contains(_activeTab)) {
|
||||
_activeTab = _ProfileTab.profile;
|
||||
}
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _loadProfile, child: Text(context.l10n.retryAction)),
|
||||
],
|
||||
),
|
||||
return buildCopyableErrorPanel(
|
||||
context: context,
|
||||
message: _error!,
|
||||
onRetry: _loadProfile,
|
||||
title: 'Kunde inte läsa profilen',
|
||||
);
|
||||
}
|
||||
|
||||
final profile = _profile!;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
child: Text(
|
||||
(_profile?.username.isNotEmpty == true
|
||||
? _profile!.username[0]
|
||||
: '?')
|
||||
.toUpperCase(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_profile?.username ?? '',
|
||||
style: theme.textTheme.titleLarge,
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
child: Text(
|
||||
(profile.username.isNotEmpty ? profile.username[0] : '?').toUpperCase(),
|
||||
),
|
||||
if ((_profile?.email ?? '').isNotEmpty)
|
||||
Text(
|
||||
_profile!.email,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(profile.username, style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
profile.email,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if ((profile.firstName ?? '').isNotEmpty || (profile.lastName ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[profile.firstName, profile.lastName]
|
||||
.where((part) => part != null && part.trim().isNotEmpty)
|
||||
.join(' '),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (profile.isAdmin)
|
||||
Chip(
|
||||
label: const Text('Admin'),
|
||||
avatar: const Icon(Icons.shield_outlined, size: 16),
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
labelStyle: TextStyle(color: theme.colorScheme.onPrimaryContainer),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_profile?.isAdmin == true)
|
||||
Chip(
|
||||
label: const Text('Admin'),
|
||||
avatar: const Icon(Icons.shield_outlined, size: 16),
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
labelStyle: TextStyle(color: theme.colorScheme.onPrimaryContainer),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildTabBar(context, tabs),
|
||||
const SizedBox(height: 16),
|
||||
_buildActiveTabContent(context, theme),
|
||||
const SizedBox(height: 24),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.link_outlined),
|
||||
title: const Text('Mina kvittoalias'),
|
||||
subtitle: const Text('Visa och hantera sparade alias från kvittoimport'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const UserAliasesScreen()),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
tileColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _logout,
|
||||
icon: const Icon(Icons.logout),
|
||||
label: Text(context.l10n.logoutAction),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Min profil', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Här uppdaterar du kontaktuppgifter och ditt namn. Alias och importrelaterad data finns i en separat vy.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildProfileForm(context, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.link_outlined),
|
||||
title: const Text('Mina kvittoalias'),
|
||||
subtitle: const Text(
|
||||
'Visa privata alias och globala fallback-alias som används i receipt-importen.',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const UserAliasesScreen()),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Snabbåtgärder', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Logga ut eller gå vidare till aliasvyn när du behöver granska importmatchningar.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _logout,
|
||||
icon: const Icon(Icons.logout),
|
||||
label: Text(context.l10n.logoutAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../admin/data/admin_repository.dart';
|
||||
import '../../admin/domain/receipt_alias.dart';
|
||||
|
||||
@@ -39,7 +40,7 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = 'Kunde inte ladda alias: $e');
|
||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
@@ -95,87 +96,148 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
||||
body: Builder(builder: (_) {
|
||||
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(onPressed: _load, child: const Text('Försök igen')),
|
||||
],
|
||||
),
|
||||
return buildCopyableErrorPanel(
|
||||
context: context,
|
||||
message: _error!,
|
||||
onRetry: _load,
|
||||
title: 'Kunde inte läsa alias',
|
||||
);
|
||||
}
|
||||
if (_aliases.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.link_off_outlined, size: 48, color: theme.colorScheme.outlineVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Inga alias sparade ännu.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Alias skapas när du väljer att lära in dem under kvittoimporten.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _aliases.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, i) {
|
||||
final alias = _aliases[i];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
alias.isGlobal ? Icons.public_outlined : Icons.link_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
alias.receiptName,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'→ ${alias.displayProductName}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(alias.isGlobal ? 'Global fallback' : 'Privat alias'),
|
||||
Text('Aliasöversikt', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Privata alias gäller bara dig. Globala alias används som fallback i receipt-importen och skapas av admin.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text('Privat alias')),
|
||||
Chip(label: Text('Global fallback')),
|
||||
Chip(label: Text('Receipt-import')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: alias.isPrivate
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Ta bort alias',
|
||||
color: theme.colorScheme.error,
|
||||
onPressed: () => _delete(alias),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.link_off_outlined, size: 48, color: theme.colorScheme.outlineVariant),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Inga alias sparade ännu.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Alias skapas när du väljer att lära in dem under kvittoimporten.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Aliasöversikt', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Privata alias gäller bara dig. Globala alias används som fallback i receipt-importen och skapas av admin.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text('Privat alias')),
|
||||
Chip(label: Text('Global fallback')),
|
||||
Chip(label: Text('Receipt-import')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _aliases.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (ctx, i) {
|
||||
final alias = _aliases[i];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
alias.isGlobal ? Icons.public_outlined : Icons.link_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
alias.receiptName,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'→ ${alias.displayProductName}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(alias.isGlobal ? 'Global fallback' : 'Privat alias'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: alias.isPrivate
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Ta bort alias',
|
||||
color: theme.colorScheme.error,
|
||||
onPressed: () => _delete(alias),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user