feat: Add inventory management feature with CRUD operations

- Implemented inventory screen to display items with details.
- Added create, edit, and consume inventory screens for managing items.
- Introduced consumption history screen to track item usage.
- Created inventory repository and providers for API interactions.
- Enhanced routing to include inventory-related paths.
- Added necessary models for inventory items and consumption history.
- Integrated error handling and loading states for better user experience.
This commit is contained in:
Nils-Johan Gynther
2026-04-22 08:12:37 +02:00
parent af1a3cd6eb
commit 967121113e
12 changed files with 1301 additions and 0 deletions
@@ -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<InventoryRepository>((ref) {
return InventoryRepository(ref.watch(apiClientProvider));
});
final inventoryProvider = FutureProvider<List<InventoryItem>>((ref) async {
final token = await ref.watch(authStateProvider.future);
return guardedApiCall(
ref,
() => ref.read(inventoryRepositoryProvider).fetchInventory(token: token),
);
});
final inventoryDetailProvider =
FutureProvider.family<InventoryItem, int>((ref, id) async {
final token = await ref.watch(authStateProvider.future);
return guardedApiCall(
ref,
() => ref.read(inventoryRepositoryProvider).fetchInventoryItem(id, token: token),
);
});
final consumptionHistoryProvider =
FutureProvider.family<List<InventoryConsumption>, int>((ref, id) async {
final token = await ref.watch(authStateProvider.future);
return guardedApiCall(
ref,
() => ref
.read(inventoryRepositoryProvider)
.fetchConsumptionHistory(id, token: token),
);
});
@@ -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<List<InventoryItem>> fetchInventory({
String? location,
String? sort,
String? token,
}) async {
final params = <String, String>{};
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<dynamic>;
return list.map((e) => InventoryItem.fromJson(e as Map<String, dynamic>)).toList();
}
Future<InventoryItem> fetchInventoryItem(int id, {String? token}) async {
final data = await _api.getJson('/inventory/$id', token: token);
return InventoryItem.fromJson(data as Map<String, dynamic>);
}
Future<InventoryItem> createInventoryItem(
Map<String, dynamic> body, {
String? token,
}) async {
final data = await _api.postJson('/inventory', body: body, token: token);
return InventoryItem.fromJson(data as Map<String, dynamic>);
}
Future<InventoryItem> updateInventoryItem(
int id,
Map<String, dynamic> body, {
String? token,
}) async {
final data = await _api.patchJson('/inventory/$id', body: body, token: token);
return InventoryItem.fromJson(data as Map<String, dynamic>);
}
Future<void> deleteInventoryItem(int id, {String? token}) async {
await _api.deleteJson('/inventory/$id', token: token);
}
Future<InventoryItem> consumeInventoryItem(
int id, {
required double amountUsed,
String? comment,
String? token,
}) async {
final body = <String, dynamic>{'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<String, dynamic>);
}
Future<List<InventoryConsumption>> fetchConsumptionHistory(
int id, {
String? token,
}) async {
final data = await _api.getJson('/inventory/$id/consumption-history', token: token);
final list = data as List<dynamic>;
return list
.map((e) => InventoryConsumption.fromJson(e as Map<String, dynamic>))
.toList();
}
}
@@ -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<String, dynamic> json) {
final itemMap = json['inventoryItem'] as Map<String, dynamic>?;
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(),
);
}
}
@@ -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<String, dynamic> json) {
return InventoryItem(
id: json['id'] as int,
productId: json['productId'] as int,
productName: (json['product'] as Map<String, dynamic>?)?['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?,
);
}
}
@@ -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<ConsumeInventoryScreen> createState() =>
_ConsumeInventoryScreenState();
}
class _ConsumeInventoryScreenState
extends ConsumerState<ConsumeInventoryScreen> {
final _formKey = GlobalKey<FormState>();
final _amountController = TextEditingController();
final _commentController = TextEditingController();
bool _saving = false;
@override
void dispose() {
_amountController.dispose();
_commentController.dispose();
super.dispose();
}
Future<void> _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'),
),
],
),
),
),
);
}
}
@@ -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,
),
);
}
}
@@ -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<CreateInventoryScreen> createState() =>
_CreateInventoryScreenState();
}
class _CreateInventoryScreenState
extends ConsumerState<CreateInventoryScreen> {
final _formKey = GlobalKey<FormState>();
final _quantityController = TextEditingController();
final _unitController = TextEditingController();
final _locationController = TextEditingController();
final _brandController = TextEditingController();
final _commentController = TextEditingController();
int? _selectedProductId;
List<Map<String, dynamic>> _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<void> _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<dynamic>)
.map((e) => e as Map<String, dynamic>)
.toList();
_loadingProducts = false;
});
}
} catch (_) {
if (mounted) setState(() => _loadingProducts = false);
}
}
Future<void> _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<void> _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 = <String, dynamic>{
'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<Map<String, dynamic>>(
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'),
),
],
),
),
);
}
}
@@ -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<bool>(
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)),
],
),
);
}
}
@@ -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<InventoryEditScreen> createState() =>
_InventoryEditScreenState();
}
class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
final _formKey = GlobalKey<FormState>();
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<void> _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<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
try {
final token = await ref.read(authStateProvider.future);
final body = <String, dynamic>{
'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'),
),
],
),
);
},
),
);
}
}
@@ -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;
}
}
}