diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index cc37177e..7f9f36d3 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -11,6 +11,12 @@ import '../../features/recipes/presentation/create_recipe_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'; final appRouterProvider = Provider((ref) { final authState = ref.watch(authStateProvider); @@ -84,6 +90,60 @@ final appRouterProvider = Provider((ref) { 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) => const CreateInventoryScreen(), + ), + 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. ShellRoute( builder: (context, state, child) { @@ -94,6 +154,10 @@ final appRouterProvider = Provider((ref) { path: '/recipes', builder: (context, state) => const RecipesScreen(), ), + GoRoute( + path: '/inventory', + builder: (context, state) => const InventoryScreen(), + ), GoRoute( path: '/profile', builder: (context, state) => const ProfileScreen(), diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index 8c3c73d0..81d1015d 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -21,6 +21,12 @@ class AppShell extends ConsumerWidget { icon: Icons.restaurant_menu, label: 'Recept', ), + _AppDestination( + path: '/inventory', + title: 'Inventarie', + icon: Icons.inventory_2_outlined, + label: 'Inventarie', + ), _AppDestination( path: '/profile', title: 'Profil', diff --git a/flutter/lib/features/inventory/data/inventory_providers.dart b/flutter/lib/features/inventory/data/inventory_providers.dart new file mode 100644 index 00000000..5054c72f --- /dev/null +++ b/flutter/lib/features/inventory/data/inventory_providers.dart @@ -0,0 +1,40 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_providers.dart'; +import '../../../core/api/guarded_api_call.dart'; +import '../../auth/data/auth_providers.dart'; +import '../domain/inventory_item.dart'; +import '../domain/inventory_consumption.dart'; +import 'inventory_repository.dart'; + +final inventoryRepositoryProvider = Provider((ref) { + return InventoryRepository(ref.watch(apiClientProvider)); +}); + +final inventoryProvider = FutureProvider>((ref) async { + final token = await ref.watch(authStateProvider.future); + return guardedApiCall( + ref, + () => ref.read(inventoryRepositoryProvider).fetchInventory(token: token), + ); +}); + +final inventoryDetailProvider = + FutureProvider.family((ref, id) async { + final token = await ref.watch(authStateProvider.future); + return guardedApiCall( + ref, + () => ref.read(inventoryRepositoryProvider).fetchInventoryItem(id, token: token), + ); +}); + +final consumptionHistoryProvider = + FutureProvider.family, int>((ref, id) async { + final token = await ref.watch(authStateProvider.future); + return guardedApiCall( + ref, + () => ref + .read(inventoryRepositoryProvider) + .fetchConsumptionHistory(id, token: token), + ); +}); diff --git a/flutter/lib/features/inventory/data/inventory_repository.dart b/flutter/lib/features/inventory/data/inventory_repository.dart new file mode 100644 index 00000000..c6003acf --- /dev/null +++ b/flutter/lib/features/inventory/data/inventory_repository.dart @@ -0,0 +1,76 @@ +import '../../../core/api/api_client.dart'; +import '../domain/inventory_item.dart'; +import '../domain/inventory_consumption.dart'; + +class InventoryRepository { + final ApiClient _api; + + const InventoryRepository(this._api); + + Future> fetchInventory({ + String? location, + String? sort, + String? token, + }) async { + final params = {}; + if (location != null && location.isNotEmpty) params['location'] = location; + if (sort != null && sort.isNotEmpty) params['sort'] = sort; + + final query = params.isEmpty + ? '' + : '?${params.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&')}'; + + final data = await _api.getJson('/inventory$query', token: token); + final list = data as List; + return list.map((e) => InventoryItem.fromJson(e as Map)).toList(); + } + + Future fetchInventoryItem(int id, {String? token}) async { + final data = await _api.getJson('/inventory/$id', token: token); + return InventoryItem.fromJson(data as Map); + } + + Future createInventoryItem( + Map body, { + String? token, + }) async { + final data = await _api.postJson('/inventory', body: body, token: token); + return InventoryItem.fromJson(data as Map); + } + + Future updateInventoryItem( + int id, + Map body, { + String? token, + }) async { + final data = await _api.patchJson('/inventory/$id', body: body, token: token); + return InventoryItem.fromJson(data as Map); + } + + Future deleteInventoryItem(int id, {String? token}) async { + await _api.deleteJson('/inventory/$id', token: token); + } + + Future consumeInventoryItem( + int id, { + required double amountUsed, + String? comment, + String? token, + }) async { + final body = {'amountUsed': amountUsed}; + if (comment != null && comment.isNotEmpty) body['comment'] = comment; + final data = await _api.postJson('/inventory/$id/consume', body: body, token: token); + return InventoryItem.fromJson(data as Map); + } + + Future> fetchConsumptionHistory( + int id, { + String? token, + }) async { + final data = await _api.getJson('/inventory/$id/consumption-history', token: token); + final list = data as List; + return list + .map((e) => InventoryConsumption.fromJson(e as Map)) + .toList(); + } +} diff --git a/flutter/lib/features/inventory/domain/inventory_consumption.dart b/flutter/lib/features/inventory/domain/inventory_consumption.dart new file mode 100644 index 00000000..873aaa22 --- /dev/null +++ b/flutter/lib/features/inventory/domain/inventory_consumption.dart @@ -0,0 +1,29 @@ +class InventoryConsumption { + final int id; + final int inventoryItemId; + final double amountUsed; + final String unit; + final String? comment; + final DateTime createdAt; + + const InventoryConsumption({ + required this.id, + required this.inventoryItemId, + required this.amountUsed, + required this.unit, + this.comment, + required this.createdAt, + }); + + factory InventoryConsumption.fromJson(Map json) { + final itemMap = json['inventoryItem'] as Map?; + return InventoryConsumption( + id: json['id'] as int, + inventoryItemId: json['inventoryItemId'] as int, + amountUsed: double.tryParse(json['amountUsed']?.toString() ?? '0') ?? 0, + unit: itemMap?['unit'] as String? ?? '', + comment: json['comment'] as String?, + createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? '') ?? DateTime.now(), + ); + } +} diff --git a/flutter/lib/features/inventory/domain/inventory_item.dart b/flutter/lib/features/inventory/domain/inventory_item.dart new file mode 100644 index 00000000..a119c676 --- /dev/null +++ b/flutter/lib/features/inventory/domain/inventory_item.dart @@ -0,0 +1,43 @@ +class InventoryItem { + final int id; + final int productId; + final String productName; + final double quantity; + final String unit; + final String? location; + final String? brand; + final String? purchaseDate; + final String? bestBeforeDate; + final bool opened; + final String? comment; + + const InventoryItem({ + required this.id, + required this.productId, + required this.productName, + required this.quantity, + required this.unit, + this.location, + this.brand, + this.purchaseDate, + this.bestBeforeDate, + required this.opened, + this.comment, + }); + + factory InventoryItem.fromJson(Map json) { + return InventoryItem( + id: json['id'] as int, + productId: json['productId'] as int, + productName: (json['product'] as Map?)?['name'] as String? ?? '', + quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0, + unit: json['unit'] as String? ?? '', + location: json['location'] as String?, + brand: json['brand'] as String?, + purchaseDate: json['purchaseDate'] as String?, + bestBeforeDate: json['bestBeforeDate'] as String?, + opened: json['opened'] as bool? ?? false, + comment: json['comment'] as String?, + ); + } +} diff --git a/flutter/lib/features/inventory/presentation/consume_inventory_screen.dart b/flutter/lib/features/inventory/presentation/consume_inventory_screen.dart new file mode 100644 index 00000000..90e343e9 --- /dev/null +++ b/flutter/lib/features/inventory/presentation/consume_inventory_screen.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/inventory_providers.dart'; + +class ConsumeInventoryScreen extends ConsumerStatefulWidget { + final int itemId; + + const ConsumeInventoryScreen({super.key, required this.itemId}); + + @override + ConsumerState createState() => + _ConsumeInventoryScreenState(); +} + +class _ConsumeInventoryScreenState + extends ConsumerState { + final _formKey = GlobalKey(); + final _amountController = TextEditingController(); + final _commentController = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _amountController.dispose(); + _commentController.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + try { + final token = await ref.read(authStateProvider.future); + final amount = double.parse( + _amountController.text.trim().replaceAll(',', '.')); + await ref.read(inventoryRepositoryProvider).consumeInventoryItem( + widget.itemId, + amountUsed: amount, + comment: _commentController.text.trim().isEmpty + ? null + : _commentController.text.trim(), + token: token, + ); + ref.invalidate(inventoryDetailProvider(widget.itemId)); + ref.invalidate(inventoryProvider); + if (mounted) context.pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e)))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final itemAsync = ref.watch(inventoryDetailProvider(widget.itemId)); + + return Scaffold( + appBar: AppBar( + title: itemAsync.maybeWhen( + data: (item) => Text('Konsumera: ${item.productName}'), + orElse: () => const Text('Konsumera'), + ), + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (itemAsync.hasValue) ...[ + Text( + 'Tillgangligt: ${itemAsync.value!.quantity} ${itemAsync.value!.unit}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + ], + TextFormField( + controller: _amountController, + decoration: InputDecoration( + labelText: 'Mangd att konsumera *', + border: const OutlineInputBorder(), + suffixText: itemAsync.maybeWhen( + data: (item) => item.unit, + orElse: () => null, + ), + ), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + autofocus: true, + enabled: !_saving, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Ange mangd'; + final parsed = + double.tryParse(v.trim().replaceAll(',', '.')); + if (parsed == null || parsed <= 0) { + return 'Ange ett positivt tal'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _commentController, + decoration: const InputDecoration( + labelText: 'Kommentar (valfri)', + border: OutlineInputBorder(), + ), + enabled: !_saving, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text('Konsumera'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/inventory/presentation/consumption_history_screen.dart b/flutter/lib/features/inventory/presentation/consumption_history_screen.dart new file mode 100644 index 00000000..17f8005d --- /dev/null +++ b/flutter/lib/features/inventory/presentation/consumption_history_screen.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/ui/async_state_views.dart'; +import '../data/inventory_providers.dart'; +import '../domain/inventory_consumption.dart'; + +class ConsumptionHistoryScreen extends ConsumerWidget { + final int itemId; + + const ConsumptionHistoryScreen({super.key, required this.itemId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final historyAsync = ref.watch(consumptionHistoryProvider(itemId)); + final itemAsync = ref.watch(inventoryDetailProvider(itemId)); + + return Scaffold( + appBar: AppBar( + title: itemAsync.maybeWhen( + data: (item) => Text('Historik: ${item.productName}'), + orElse: () => const Text('Konsumtionshistorik'), + ), + ), + body: historyAsync.when( + loading: () => const LoadingStateView(label: 'Laddar historik...'), + error: (e, _) => ErrorStateView( + message: e.toString(), + onRetry: () => ref.invalidate(consumptionHistoryProvider(itemId)), + ), + data: (history) { + if (history.isEmpty) { + return const EmptyStateView( + message: 'Ingen konsumtionshistorik finns.', + ); + } + return ListView.separated( + itemCount: history.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final entry = history[index]; + return _HistoryTile(entry: entry); + }, + ); + }, + ), + ); + } +} + +class _HistoryTile extends StatelessWidget { + final InventoryConsumption entry; + + const _HistoryTile({required this.entry}); + + @override + Widget build(BuildContext context) { + final dateStr = + '${entry.createdAt.year}-${entry.createdAt.month.toString().padLeft(2, '0')}-${entry.createdAt.day.toString().padLeft(2, '0')}'; + final timeStr = + '${entry.createdAt.hour.toString().padLeft(2, '0')}:${entry.createdAt.minute.toString().padLeft(2, '0')}'; + + return ListTile( + leading: const Icon(Icons.remove_circle_outline), + title: Text('${entry.amountUsed} ${entry.unit}'), + subtitle: entry.comment != null && entry.comment!.isNotEmpty + ? Text(entry.comment!) + : null, + trailing: Text( + '$dateStr $timeStr', + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } +} diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart new file mode 100644 index 00000000..35ddbc0e --- /dev/null +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/api/api_providers.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/inventory_providers.dart'; + +class CreateInventoryScreen extends ConsumerStatefulWidget { + const CreateInventoryScreen({super.key}); + + @override + ConsumerState createState() => + _CreateInventoryScreenState(); +} + +class _CreateInventoryScreenState + extends ConsumerState { + final _formKey = GlobalKey(); + final _quantityController = TextEditingController(); + final _unitController = TextEditingController(); + final _locationController = TextEditingController(); + final _brandController = TextEditingController(); + final _commentController = TextEditingController(); + + int? _selectedProductId; + List> _products = []; + bool _loadingProducts = false; + DateTime? _purchaseDate; + DateTime? _bestBeforeDate; + bool _opened = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + _loadProducts(); + } + + @override + void dispose() { + _quantityController.dispose(); + _unitController.dispose(); + _locationController.dispose(); + _brandController.dispose(); + _commentController.dispose(); + super.dispose(); + } + + Future _loadProducts() async { + setState(() => _loadingProducts = true); + try { + final token = await ref.read(authStateProvider.future); + final api = ref.read(apiClientProvider); + final data = await api.getJson('/products', token: token); + if (mounted) { + setState(() { + _products = (data as List) + .map((e) => e as Map) + .toList(); + _loadingProducts = false; + }); + } + } catch (_) { + if (mounted) setState(() => _loadingProducts = false); + } + } + + Future _pickDate(bool isBestBefore) async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() { + if (isBestBefore) { + _bestBeforeDate = picked; + } else { + _purchaseDate = picked; + } + }); + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + if (_selectedProductId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Valj en produkt ur listan.')), + ); + return; + } + setState(() => _saving = true); + try { + final token = await ref.read(authStateProvider.future); + final body = { + 'productId': _selectedProductId, + 'quantity': + double.parse(_quantityController.text.trim().replaceAll(',', '.')), + 'unit': _unitController.text.trim(), + if (_locationController.text.trim().isNotEmpty) + 'location': _locationController.text.trim(), + if (_brandController.text.trim().isNotEmpty) + 'brand': _brandController.text.trim(), + if (_purchaseDate != null) + 'purchaseDate': _purchaseDate!.toIso8601String(), + if (_bestBeforeDate != null) + 'bestBeforeDate': _bestBeforeDate!.toIso8601String(), + 'opened': _opened, + if (_commentController.text.trim().isNotEmpty) + 'comment': _commentController.text.trim(), + }; + await ref + .read(inventoryRepositoryProvider) + .createInventoryItem(body, token: token); + ref.invalidate(inventoryProvider); + if (mounted) context.pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e)))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + String _formatDate(DateTime? dt) { + if (dt == null) return 'Valj datum'; + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Lagg till inventariepost')), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Autocomplete>( + optionsBuilder: (textEditingValue) { + if (textEditingValue.text.isEmpty) return const []; + final q = textEditingValue.text.toLowerCase(); + return _products + .where((p) => + (p['name'] as String).toLowerCase().contains(q)) + .take(10); + }, + displayStringForOption: (option) => option['name'] as String, + onSelected: (option) { + setState(() => _selectedProductId = option['id'] as int); + }, + fieldViewBuilder: + (context, controller, focusNode, onFieldSubmitted) { + return TextFormField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + labelText: 'Produkt *', + border: const OutlineInputBorder(), + suffixIcon: _loadingProducts + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : null, + ), + enabled: !_saving, + ); + }, + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: TextFormField( + controller: _quantityController, + decoration: const InputDecoration( + labelText: 'Mangd *', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + enabled: !_saving, + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Ange mangd'; + if (double.tryParse(v.trim().replaceAll(',', '.')) == + null) { + return 'Ogiltigt tal'; + } + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _unitController, + decoration: const InputDecoration( + labelText: 'Enhet *', + border: OutlineInputBorder(), + ), + enabled: !_saving, + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Ange enhet' : null, + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: _locationController, + decoration: const InputDecoration( + labelText: 'Plats (valfri)', + border: OutlineInputBorder(), + ), + enabled: !_saving, + ), + const SizedBox(height: 12), + TextFormField( + controller: _brandController, + decoration: const InputDecoration( + labelText: 'Marke (valfritt)', + border: OutlineInputBorder(), + ), + enabled: !_saving, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _saving ? null : () => _pickDate(false), + icon: const Icon(Icons.calendar_today, size: 16), + label: Text( + 'Inkop: ${_formatDate(_purchaseDate)}', + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _saving ? null : () => _pickDate(true), + icon: const Icon(Icons.event_available, size: 16), + label: Text( + 'Bast fore: ${_formatDate(_bestBeforeDate)}', + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + CheckboxListTile( + title: const Text('Oppnad'), + value: _opened, + onChanged: + _saving ? null : (v) => setState(() => _opened = v ?? false), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + TextFormField( + controller: _commentController, + decoration: const InputDecoration( + labelText: 'Kommentar (valfri)', + border: OutlineInputBorder(), + ), + maxLines: 2, + enabled: !_saving, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text('Spara'), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/inventory/presentation/inventory_detail_screen.dart b/flutter/lib/features/inventory/presentation/inventory_detail_screen.dart new file mode 100644 index 00000000..cf899b19 --- /dev/null +++ b/flutter/lib/features/inventory/presentation/inventory_detail_screen.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/ui/async_state_views.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/inventory_providers.dart'; + +class InventoryDetailScreen extends ConsumerWidget { + final int itemId; + + const InventoryDetailScreen({super.key, required this.itemId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final itemAsync = ref.watch(inventoryDetailProvider(itemId)); + + return Scaffold( + appBar: AppBar( + title: itemAsync.maybeWhen( + data: (item) => Text(item.productName), + orElse: () => const Text('Inventarie'), + ), + actions: [ + if (itemAsync.hasValue) ...[ + IconButton( + tooltip: 'Redigera', + icon: const Icon(Icons.edit_outlined), + onPressed: () => context.push('/inventory/$itemId/edit'), + ), + _DeleteButton(itemId: itemId), + ], + ], + ), + body: itemAsync.when( + loading: () => const LoadingStateView(label: 'Laddar...'), + error: (e, _) => ErrorStateView( + message: e.toString(), + onRetry: () => ref.invalidate(inventoryDetailProvider(itemId)), + ), + data: (item) => ListView( + padding: const EdgeInsets.all(16), + children: [ + _InfoRow(label: 'Produkt', value: item.productName), + _InfoRow( + label: 'Mängd', + value: '${item.quantity} ${item.unit}', + ), + if (item.location != null && item.location!.isNotEmpty) + _InfoRow(label: 'Plats', value: item.location!), + if (item.brand != null && item.brand!.isNotEmpty) + _InfoRow(label: 'Märke', value: item.brand!), + if (item.purchaseDate != null) + _InfoRow(label: 'Inköpsdatum', value: _formatDate(item.purchaseDate!)), + if (item.bestBeforeDate != null) + _InfoRow(label: 'Bäst före', value: _formatDate(item.bestBeforeDate!)), + _InfoRow(label: 'Öppnad', value: item.opened ? 'Ja' : 'Nej'), + if (item.comment != null && item.comment!.isNotEmpty) + _InfoRow(label: 'Kommentar', value: item.comment!), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: () => context.push('/inventory/$itemId/consume'), + icon: const Icon(Icons.remove_circle_outline), + label: const Text('Konsumera'), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => context.push('/inventory/$itemId/history'), + icon: const Icon(Icons.history), + label: const Text('Konsumtionshistorik'), + ), + ], + ), + ), + ); + } + + String _formatDate(String iso) { + try { + final dt = DateTime.parse(iso); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + } catch (_) { + return iso; + } + } +} + +class _DeleteButton extends ConsumerWidget { + final int itemId; + + const _DeleteButton({required this.itemId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + tooltip: 'Ta bort', + icon: const Icon(Icons.delete_outline), + onPressed: () async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Ta bort inventariepost?'), + content: const Text('Åtgärden kan inte ångras.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Ta bort'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + try { + final token = await ref.read(authStateProvider.future); + await ref + .read(inventoryRepositoryProvider) + .deleteInventoryItem(itemId, token: token); + ref.invalidate(inventoryProvider); + if (context.mounted) context.go('/inventory'); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e))), + ); + } + } + }, + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + + const _InfoRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded(child: Text(value)), + ], + ), + ); + } +} diff --git a/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart new file mode 100644 index 00000000..1722d326 --- /dev/null +++ b/flutter/lib/features/inventory/presentation/inventory_edit_screen.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../../../core/ui/async_state_views.dart'; +import '../../auth/data/auth_providers.dart'; +import '../data/inventory_providers.dart'; +import '../domain/inventory_item.dart'; + +class InventoryEditScreen extends ConsumerStatefulWidget { + final int itemId; + + const InventoryEditScreen({super.key, required this.itemId}); + + @override + ConsumerState createState() => + _InventoryEditScreenState(); +} + +class _InventoryEditScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _quantityController = TextEditingController(); + final _unitController = TextEditingController(); + final _locationController = TextEditingController(); + final _brandController = TextEditingController(); + final _commentController = TextEditingController(); + + bool _initialized = false; + bool _opened = false; + DateTime? _purchaseDate; + DateTime? _bestBeforeDate; + bool _saving = false; + + @override + void dispose() { + _quantityController.dispose(); + _unitController.dispose(); + _locationController.dispose(); + _brandController.dispose(); + _commentController.dispose(); + super.dispose(); + } + + void _initControllers(InventoryItem item) { + _initialized = true; + _quantityController.text = item.quantity.toString(); + _unitController.text = item.unit; + _locationController.text = item.location ?? ''; + _brandController.text = item.brand ?? ''; + _commentController.text = item.comment ?? ''; + _opened = item.opened; + _purchaseDate = + item.purchaseDate != null ? DateTime.tryParse(item.purchaseDate!) : null; + _bestBeforeDate = item.bestBeforeDate != null + ? DateTime.tryParse(item.bestBeforeDate!) + : null; + } + + Future _pickDate(bool isBestBefore) async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() { + if (isBestBefore) { + _bestBeforeDate = picked; + } else { + _purchaseDate = picked; + } + }); + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + try { + final token = await ref.read(authStateProvider.future); + final body = { + 'quantity': + double.parse(_quantityController.text.trim().replaceAll(',', '.')), + 'unit': _unitController.text.trim(), + 'location': _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), + 'brand': _brandController.text.trim().isEmpty + ? null + : _brandController.text.trim(), + if (_purchaseDate != null) + 'purchaseDate': _purchaseDate!.toIso8601String(), + if (_bestBeforeDate != null) + 'bestBeforeDate': _bestBeforeDate!.toIso8601String(), + 'opened': _opened, + 'comment': _commentController.text.trim().isEmpty + ? null + : _commentController.text.trim(), + }; + await ref + .read(inventoryRepositoryProvider) + .updateInventoryItem(widget.itemId, body, token: token); + ref.invalidate(inventoryDetailProvider(widget.itemId)); + ref.invalidate(inventoryProvider); + if (mounted) context.pop(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(mapErrorToUserMessage(e)))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + String _formatDate(DateTime? dt) { + if (dt == null) return 'Valj datum'; + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final itemAsync = ref.watch(inventoryDetailProvider(widget.itemId)); + + return Scaffold( + appBar: AppBar(title: const Text('Redigera inventariepost')), + body: itemAsync.when( + loading: () => const LoadingStateView(label: 'Laddar...'), + error: (e, _) => ErrorStateView( + message: e.toString(), + onRetry: () => ref.invalidate(inventoryDetailProvider(widget.itemId)), + ), + data: (item) { + if (!_initialized) _initControllers(item); + return Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + item.productName, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: TextFormField( + controller: _quantityController, + decoration: const InputDecoration( + labelText: 'Mangd *', + border: OutlineInputBorder(), + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + enabled: !_saving, + validator: (v) { + if (v == null || v.trim().isEmpty) { + return 'Ange mangd'; + } + if (double.tryParse( + v.trim().replaceAll(',', '.')) == + null) { + return 'Ogiltigt tal'; + } + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _unitController, + decoration: const InputDecoration( + labelText: 'Enhet *', + border: OutlineInputBorder(), + ), + enabled: !_saving, + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Ange enhet' + : null, + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: _locationController, + decoration: const InputDecoration( + labelText: 'Plats', + border: OutlineInputBorder(), + ), + enabled: !_saving, + ), + const SizedBox(height: 12), + TextFormField( + controller: _brandController, + decoration: const InputDecoration( + labelText: 'Marke', + border: OutlineInputBorder(), + ), + enabled: !_saving, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _saving ? null : () => _pickDate(false), + icon: const Icon(Icons.calendar_today, size: 16), + label: Text( + 'Inkop: ${_formatDate(_purchaseDate)}', + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: _saving ? null : () => _pickDate(true), + icon: const Icon(Icons.event_available, size: 16), + label: Text( + 'Bast fore: ${_formatDate(_bestBeforeDate)}', + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + CheckboxListTile( + title: const Text('Oppnad'), + value: _opened, + onChanged: _saving + ? null + : (v) => setState(() => _opened = v ?? false), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + TextFormField( + controller: _commentController, + decoration: const InputDecoration( + labelText: 'Kommentar', + border: OutlineInputBorder(), + ), + maxLines: 2, + enabled: !_saving, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text('Spara'), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart new file mode 100644 index 00000000..2050c5fc --- /dev/null +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/ui/async_state_views.dart'; +import '../data/inventory_providers.dart'; +import '../domain/inventory_item.dart'; + +class InventoryScreen extends ConsumerWidget { + const InventoryScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final inventoryAsync = ref.watch(inventoryProvider); + + return inventoryAsync.when( + loading: () => const LoadingStateView(label: 'Laddar inventarie...'), + error: (e, _) => ErrorStateView( + message: e.toString(), + onRetry: () => ref.invalidate(inventoryProvider), + ), + data: (items) { + if (items.isEmpty) { + return EmptyStateView( + message: 'Inventariet är tomt.', + action: FloatingActionButton.extended( + onPressed: () => context.push('/inventory/create'), + icon: const Icon(Icons.add), + label: const Text('Lägg till'), + ), + ); + } + return Stack( + children: [ + ListView.separated( + padding: const EdgeInsets.only(bottom: 88), + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = items[index]; + return _InventoryTile(item: item); + }, + ), + Positioned( + right: 16, + bottom: 16, + child: FloatingActionButton.extended( + onPressed: () => context.push('/inventory/create'), + icon: const Icon(Icons.add), + label: const Text('Lägg till'), + ), + ), + ], + ); + }, + ); + } +} + +class _InventoryTile extends StatelessWidget { + final InventoryItem item; + + const _InventoryTile({required this.item}); + + @override + Widget build(BuildContext context) { + final subtitle = [ + '${item.quantity} ${item.unit}', + if (item.location != null && item.location!.isNotEmpty) item.location!, + if (item.bestBeforeDate != null) 'Bäst före: ${_formatDate(item.bestBeforeDate!)}', + ].join(' · '); + + return ListTile( + title: Text(item.productName), + subtitle: Text(subtitle), + trailing: item.opened + ? const Chip( + label: Text('Öppnad'), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ) + : null, + onTap: () => context.push('/inventory/${item.id}'), + ); + } + + String _formatDate(String iso) { + try { + final dt = DateTime.parse(iso); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + } catch (_) { + return iso; + } + } +}