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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user