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:
@@ -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<GoRouter>((ref) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
@@ -84,6 +90,60 @@ final appRouterProvider = Provider<GoRouter>((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<GoRouter>((ref) {
|
||||
path: '/recipes',
|
||||
builder: (context, state) => const RecipesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/inventory',
|
||||
builder: (context, state) => const InventoryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user