feat(inventory): implement swipeable inventory tile and product picker field

This commit is contained in:
Nils-Johan Gynther
2026-04-22 21:19:36 +02:00
parent b04a82aaf8
commit 14d782aeec
6 changed files with 632 additions and 170 deletions
@@ -6,6 +6,7 @@ import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/forms/form_options.dart';
import '../../../core/ui/product_picker_field.dart';
import '../../auth/data/auth_providers.dart';
import '../data/inventory_providers.dart';
@@ -143,6 +144,14 @@ class _CreateInventoryScreenState
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString();
return aName.toLowerCase().compareTo(bName.toLowerCase());
});
final productOptions = sortedProducts
.map(
(p) => (
id: p['id'] as int,
name: (p['canonicalName'] ?? p['name'] ?? '').toString(),
),
)
.toList();
return Scaffold(
appBar: AppBar(title: const Text('Lägg till inventariepost')),
@@ -151,39 +160,13 @@ class _CreateInventoryScreenState
child: ListView(
padding: const EdgeInsets.all(16),
children: [
DropdownButtonFormField<int>(
ProductPickerField(
products: productOptions,
value: _selectedProductId,
isExpanded: true,
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,
),
items: sortedProducts
.map(
(product) => DropdownMenuItem<int>(
value: product['id'] as int,
child: Text(
((product['canonicalName'] ?? product['name']) as Object)
.toString(),
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: (_loadingProducts || _saving)
? null
: (value) => setState(() => _selectedProductId = value),
validator: (value) => value == null ? 'Välj produkt' : null,
isLoading: _loadingProducts,
enabled: !_saving,
label: 'Produkt *',
onChanged: (value) => setState(() => _selectedProductId = value),
),
const SizedBox(height: 12),
Row(
@@ -7,6 +7,7 @@ import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/inventory_providers.dart';
import '../domain/inventory_item.dart';
import 'swipeable_inventory_tile.dart';
class InventoryScreen extends ConsumerWidget {
const InventoryScreen({super.key});
@@ -113,7 +114,7 @@ class InventoryScreen extends ConsumerWidget {
itemBuilder: (context, index) {
if (index == 0) return filterSection;
final item = items[index - 1];
return _InventoryTile(item: item);
return SwipeableInventoryTile(item: item);
},
),
Positioned(
@@ -132,111 +133,4 @@ class InventoryScreen extends ConsumerWidget {
}
}
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: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.opened)
const Padding(
padding: EdgeInsets.only(right: 4),
child: Chip(
label: Text('Öppnad'),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
Tooltip(
message: 'Konsumera',
child: IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () => context.push('/inventory/${item.id}/consume'),
),
),
Tooltip(
message: 'Redigera',
child: IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => context.push('/inventory/${item.id}/edit'),
),
),
_DeleteInventoryButton(item: item),
],
),
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;
}
}
}
class _DeleteInventoryButton extends ConsumerWidget {
final InventoryItem item;
const _DeleteInventoryButton({required this.item});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Tooltip(
message: 'Ta bort',
child: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ta bort inventariepost?'),
content: Text('Vill du ta bort "${item.productName}"?'),
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) return;
try {
final token = await ref.read(authStateProvider.future);
await ref
.read(inventoryRepositoryProvider)
.deleteInventoryItem(item.id, token: token);
ref.invalidate(inventoryProvider);
} catch (error) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(error, context))),
);
}
},
),
);
}
}
@@ -0,0 +1,351 @@
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';
import '../domain/inventory_item.dart';
/// A [ListTile] wrapped in a swipe-to-adjust widget.
///
/// • Swipe **right** (+) → adds 1 unit to [item.quantity] via PATCH.
/// • Swipe **left** () → consumes 1 unit via the consume endpoint,
/// preserving consumption history.
///
/// A small swipe-hint icon is shown at the start of the subtitle so users
/// know the gesture is available without any extra instruction.
class SwipeableInventoryTile extends ConsumerStatefulWidget {
final InventoryItem item;
const SwipeableInventoryTile({super.key, required this.item});
@override
ConsumerState<SwipeableInventoryTile> createState() =>
_SwipeableInventoryTileState();
}
class _SwipeableInventoryTileState
extends ConsumerState<SwipeableInventoryTile> {
/// Pixels dragged horizontally. Positive = right, negative = left.
double _drag = 0;
/// While true, ignore new drag gestures (API call in flight).
bool _acting = false;
static const _threshold = 72.0; // minimum drag to trigger an action
static const _maxReveal = 88.0; // maximum horizontal travel shown
// ── Gesture handlers ────────────────────────────────────────────────────
void _onUpdate(DragUpdateDetails d) {
if (_acting) return;
setState(() {
_drag = (_drag + d.delta.dx).clamp(-_maxReveal, _maxReveal);
});
}
void _onEnd(DragEndDetails d) {
if (_acting) return;
if (_drag > _threshold) {
_adjust(+1);
} else if (_drag < -_threshold) {
_adjust(-1);
}
_snapBack();
}
void _snapBack() => setState(() => _drag = 0);
Future<void> _adjust(int direction) async {
setState(() => _acting = true);
try {
final token = await ref.read(authStateProvider.future);
final repo = ref.read(inventoryRepositoryProvider);
if (direction > 0) {
// Increase: direct PATCH with new quantity.
await repo.updateInventoryItem(
widget.item.id,
{'quantity': widget.item.quantity + 1},
token: token,
);
} else {
// Decrease: use consume endpoint so history is preserved.
// Guard against going below zero.
if (widget.item.quantity <= 0) return;
await repo.consumeInventoryItem(
widget.item.id,
amountUsed: 1,
token: token,
);
}
ref.invalidate(inventoryProvider);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
);
} finally {
if (mounted) setState(() => _acting = false);
}
}
// ── Build ────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// How far (0.01.0) we are from triggering an action, used for opacity.
final rightProgress = (_drag / _threshold).clamp(0.0, 1.0);
final leftProgress = (-_drag / _threshold).clamp(0.0, 1.0);
return GestureDetector(
onHorizontalDragUpdate: _onUpdate,
onHorizontalDragEnd: _onEnd,
onHorizontalDragCancel: _snapBack,
behavior: HitTestBehavior.opaque,
child: ClipRect(
child: Stack(
children: [
// ── Right-swipe background (add +1) ─────────────────────────
Positioned.fill(
child: Row(
children: [
Expanded(
child: Opacity(
opacity: rightProgress,
child: Container(
color: colorScheme.primaryContainer,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.add_circle_outline,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(height: 2),
Text(
'+1',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
],
),
),
// ── Left-swipe background (remove 1) ───────────────────────
Positioned.fill(
child: Row(
children: [
const Spacer(),
Expanded(
child: Opacity(
opacity: leftProgress,
child: Container(
color: colorScheme.tertiaryContainer,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.remove_circle_outline,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(height: 2),
Text(
'1',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
],
),
),
// ── Foreground tile ─────────────────────────────────────────
Transform.translate(
offset: Offset(_drag, 0),
child: _ForegroundTile(item: widget.item, acting: _acting),
),
],
),
),
);
}
}
// ── Foreground tile ──────────────────────────────────────────────────────────
class _ForegroundTile extends ConsumerWidget {
final InventoryItem item;
final bool acting;
const _ForegroundTile({required this.item, required this.acting});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final subtitleText = [
'${_fmtQty(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 ColoredBox(
// Opaque background so the tile sits on top of the reveal panels.
color: theme.colorScheme.surface,
child: ListTile(
title: Text(item.productName),
// Subtitle: small swipe-hint icon + text on the same row.
subtitle: Row(
children: [
Icon(
Icons.swap_horiz,
size: 13,
color: theme.colorScheme.outline,
),
const SizedBox(width: 3),
Expanded(
child: Text(
subtitleText,
overflow: TextOverflow.ellipsis,
),
),
],
),
trailing: acting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _TrailingActions(item: item),
onTap: () => context.push('/inventory/${item.id}'),
),
);
}
String _fmtQty(double v) =>
v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toStringAsFixed(1);
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;
}
}
}
// ── Trailing action buttons ─────────────────────────────────────────────────
class _TrailingActions extends ConsumerWidget {
final InventoryItem item;
const _TrailingActions({required this.item});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.opened)
const Padding(
padding: EdgeInsets.only(right: 4),
child: Chip(
label: Text('Öppnad'),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
Tooltip(
message: 'Konsumera',
child: IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () => context.push('/inventory/${item.id}/consume'),
),
),
Tooltip(
message: 'Redigera',
child: IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => context.push('/inventory/${item.id}/edit'),
),
),
_DeleteButton(item: item),
],
);
}
}
class _DeleteButton extends ConsumerWidget {
final InventoryItem item;
const _DeleteButton({required this.item});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Tooltip(
message: 'Ta bort',
child: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ta bort inventariepost?'),
content: Text('Vill du ta bort "${item.productName}"?'),
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) return;
try {
final token = await ref.read(authStateProvider.future);
await ref
.read(inventoryRepositoryProvider)
.deleteInventoryItem(item.id, token: token);
ref.invalidate(inventoryProvider);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
);
}
},
),
);
}
}
@@ -24,18 +24,16 @@ final mealPlanDashboardProvider = FutureProvider<MealPlanDashboard>((ref) async
return guardedApiCall(ref, () async {
final repository = ref.read(mealPlanRepositoryProvider);
final entries = await repository.fetchEntries(week.fromIso, week.toIso, token: token);
final shoppingItems = await repository.fetchShoppingList(week.fromIso, week.toIso, token: token);
final inventoryCompareItems = await repository.fetchInventoryCompare(
week.fromIso,
week.toIso,
token: token,
);
// Start all three requests in parallel.
final entriesFuture = repository.fetchEntries(week.fromIso, week.toIso, token: token);
final shoppingFuture = repository.fetchShoppingList(week.fromIso, week.toIso, token: token);
final compareFuture = repository.fetchInventoryCompare(week.fromIso, week.toIso, token: token);
return MealPlanDashboard(
entries: entries,
shoppingItems: shoppingItems,
inventoryCompareItems: inventoryCompareItems,
entries: await entriesFuture,
shoppingItems: await shoppingFuture,
inventoryCompareItems: await compareFuture,
);
});
});
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/forms/form_options.dart';
import '../../../core/ui/product_picker_field.dart';
import '../../../features/inventory/data/inventory_providers.dart';
import '../../auth/data/auth_providers.dart';
import '../../../core/ui/async_state_views.dart';
@@ -264,6 +265,9 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
(a, b) =>
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
);
final availableOptions = availableProducts
.map((p) => (id: p.id, name: p.displayName))
.toList();
final grouped = <String, List<PantryItem>>{};
for (final item in pantryItems) {
@@ -289,27 +293,12 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField<int>(
child: ProductPickerField(
products: availableOptions,
value: _selectedProductId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Produkt',
border: OutlineInputBorder(),
),
items: availableProducts
.map(
(product) => DropdownMenuItem<int>(
value: product.id,
child: Text(
product.displayName,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: _isSubmitting
? null
: (value) => setState(() => _selectedProductId = value),
enabled: !_isSubmitting && availableProducts.isNotEmpty,
label: 'Produkt',
onChanged: (value) => setState(() => _selectedProductId = value),
),
),
const SizedBox(width: 8),