feat(inventory): implement swipeable inventory tile and product picker field
This commit is contained in:
@@ -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.0–1.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))),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user