feat(inventory): implement swipeable inventory tile and product picker field
This commit is contained in:
@@ -0,0 +1,247 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A named record representing a selectable product option.
|
||||||
|
typedef ProductOption = ({int id, String name});
|
||||||
|
|
||||||
|
/// A form field that opens a searchable bottom sheet for selecting a product.
|
||||||
|
///
|
||||||
|
/// Replaces a long [DropdownButtonFormField] when the product list is large.
|
||||||
|
/// Works both inside and outside a [Form].
|
||||||
|
class ProductPickerField extends StatelessWidget {
|
||||||
|
final List<ProductOption> products;
|
||||||
|
|
||||||
|
/// Currently selected product id, or null if nothing is selected.
|
||||||
|
final int? value;
|
||||||
|
|
||||||
|
/// Called when the user picks a product.
|
||||||
|
final ValueChanged<int?>? onChanged;
|
||||||
|
|
||||||
|
/// Whether the field is interactive.
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// Shows a loading spinner inside the field while products are being fetched.
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
/// Label shown in the field border.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Inline error message shown below the field.
|
||||||
|
final String? errorText;
|
||||||
|
|
||||||
|
const ProductPickerField({
|
||||||
|
super.key,
|
||||||
|
required this.products,
|
||||||
|
this.value,
|
||||||
|
this.onChanged,
|
||||||
|
this.enabled = true,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.label = 'Produkt',
|
||||||
|
this.errorText,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final selected = value == null
|
||||||
|
? null
|
||||||
|
: products.cast<ProductOption?>().firstWhere(
|
||||||
|
(p) => p?.id == value,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final interactive = enabled && !isLoading;
|
||||||
|
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: interactive ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: interactive ? () => _openPicker(context) : null,
|
||||||
|
child: InputDecorator(
|
||||||
|
isEmpty: value == null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: errorText,
|
||||||
|
enabled: interactive,
|
||||||
|
suffixIcon: isLoading
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.search),
|
||||||
|
),
|
||||||
|
child: value == null
|
||||||
|
? null
|
||||||
|
: Text(
|
||||||
|
selected?.name ?? '',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openPicker(BuildContext context) async {
|
||||||
|
final result = await showModalBottomSheet<ProductOption>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (_) => _ProductPickerSheet(
|
||||||
|
products: products,
|
||||||
|
selectedId: value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
onChanged?.call(result.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _ProductPickerSheet extends StatefulWidget {
|
||||||
|
final List<ProductOption> products;
|
||||||
|
final int? selectedId;
|
||||||
|
|
||||||
|
const _ProductPickerSheet({
|
||||||
|
required this.products,
|
||||||
|
this.selectedId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ProductPickerSheet> createState() => _ProductPickerSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProductPickerSheetState extends State<_ProductPickerSheet> {
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
late List<ProductOption> _filtered;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_filtered = widget.products;
|
||||||
|
_searchController.addListener(_onSearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearch() {
|
||||||
|
final query = _searchController.text.trim().toLowerCase();
|
||||||
|
setState(() {
|
||||||
|
_filtered = query.isEmpty
|
||||||
|
? widget.products
|
||||||
|
: widget.products
|
||||||
|
.where((p) => p.name.toLowerCase().contains(query))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
expand: false,
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
minChildSize: 0.35,
|
||||||
|
maxChildSize: 0.92,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Drag handle
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Search field
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
autofocus: true,
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Sök produkt...',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: ListenableBuilder(
|
||||||
|
listenable: _searchController,
|
||||||
|
builder: (_, __) => _searchController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
tooltip: 'Rensa sökning',
|
||||||
|
onPressed: _searchController.clear,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
|
// Result count
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
'${_filtered.length} ${_filtered.length == 1 ? 'produkt' : 'produkter'}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(height: 1),
|
||||||
|
|
||||||
|
// Product list
|
||||||
|
Expanded(
|
||||||
|
child: _filtered.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'Inga produkter matchade\n"${_searchController.text}"',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
controller: scrollController,
|
||||||
|
itemCount: _filtered.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final product = _filtered[index];
|
||||||
|
final isSelected = product.id == widget.selectedId;
|
||||||
|
return ListTile(
|
||||||
|
title: Text(product.name),
|
||||||
|
selected: isSelected,
|
||||||
|
trailing: isSelected
|
||||||
|
? Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => Navigator.pop(context, product),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import '../../../core/api/api_error_mapper.dart';
|
|||||||
import '../../../core/api/api_paths.dart';
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../../../core/api/api_providers.dart';
|
import '../../../core/api/api_providers.dart';
|
||||||
import '../../../core/forms/form_options.dart';
|
import '../../../core/forms/form_options.dart';
|
||||||
|
import '../../../core/ui/product_picker_field.dart';
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../data/inventory_providers.dart';
|
import '../data/inventory_providers.dart';
|
||||||
|
|
||||||
@@ -143,6 +144,14 @@ class _CreateInventoryScreenState
|
|||||||
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString();
|
final bName = (b['canonicalName'] ?? b['name'] ?? '').toString();
|
||||||
return aName.toLowerCase().compareTo(bName.toLowerCase());
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Lägg till inventariepost')),
|
appBar: AppBar(title: const Text('Lägg till inventariepost')),
|
||||||
@@ -151,39 +160,13 @@ class _CreateInventoryScreenState
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<int>(
|
ProductPickerField(
|
||||||
|
products: productOptions,
|
||||||
value: _selectedProductId,
|
value: _selectedProductId,
|
||||||
isExpanded: true,
|
isLoading: _loadingProducts,
|
||||||
decoration: InputDecoration(
|
enabled: !_saving,
|
||||||
labelText: 'Produkt *',
|
label: 'Produkt *',
|
||||||
border: const OutlineInputBorder(),
|
onChanged: (value) => setState(() => _selectedProductId = value),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../core/ui/async_state_views.dart';
|
|||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../data/inventory_providers.dart';
|
import '../data/inventory_providers.dart';
|
||||||
import '../domain/inventory_item.dart';
|
import '../domain/inventory_item.dart';
|
||||||
|
import 'swipeable_inventory_tile.dart';
|
||||||
|
|
||||||
class InventoryScreen extends ConsumerWidget {
|
class InventoryScreen extends ConsumerWidget {
|
||||||
const InventoryScreen({super.key});
|
const InventoryScreen({super.key});
|
||||||
@@ -113,7 +114,7 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == 0) return filterSection;
|
if (index == 0) return filterSection;
|
||||||
final item = items[index - 1];
|
final item = items[index - 1];
|
||||||
return _InventoryTile(item: item);
|
return SwipeableInventoryTile(item: item);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Positioned(
|
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))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,18 +24,16 @@ final mealPlanDashboardProvider = FutureProvider<MealPlanDashboard>((ref) async
|
|||||||
|
|
||||||
return guardedApiCall(ref, () async {
|
return guardedApiCall(ref, () async {
|
||||||
final repository = ref.read(mealPlanRepositoryProvider);
|
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);
|
// Start all three requests in parallel.
|
||||||
final inventoryCompareItems = await repository.fetchInventoryCompare(
|
final entriesFuture = repository.fetchEntries(week.fromIso, week.toIso, token: token);
|
||||||
week.fromIso,
|
final shoppingFuture = repository.fetchShoppingList(week.fromIso, week.toIso, token: token);
|
||||||
week.toIso,
|
final compareFuture = repository.fetchInventoryCompare(week.fromIso, week.toIso, token: token);
|
||||||
token: token,
|
|
||||||
);
|
|
||||||
|
|
||||||
return MealPlanDashboard(
|
return MealPlanDashboard(
|
||||||
entries: entries,
|
entries: await entriesFuture,
|
||||||
shoppingItems: shoppingItems,
|
shoppingItems: await shoppingFuture,
|
||||||
inventoryCompareItems: inventoryCompareItems,
|
inventoryCompareItems: await compareFuture,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
import '../../../core/forms/form_options.dart';
|
import '../../../core/forms/form_options.dart';
|
||||||
|
import '../../../core/ui/product_picker_field.dart';
|
||||||
import '../../../features/inventory/data/inventory_providers.dart';
|
import '../../../features/inventory/data/inventory_providers.dart';
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../../../core/ui/async_state_views.dart';
|
import '../../../core/ui/async_state_views.dart';
|
||||||
@@ -264,6 +265,9 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
(a, b) =>
|
(a, b) =>
|
||||||
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
final availableOptions = availableProducts
|
||||||
|
.map((p) => (id: p.id, name: p.displayName))
|
||||||
|
.toList();
|
||||||
|
|
||||||
final grouped = <String, List<PantryItem>>{};
|
final grouped = <String, List<PantryItem>>{};
|
||||||
for (final item in pantryItems) {
|
for (final item in pantryItems) {
|
||||||
@@ -289,27 +293,12 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<int>(
|
child: ProductPickerField(
|
||||||
|
products: availableOptions,
|
||||||
value: _selectedProductId,
|
value: _selectedProductId,
|
||||||
isExpanded: true,
|
enabled: !_isSubmitting && availableProducts.isNotEmpty,
|
||||||
decoration: const InputDecoration(
|
label: 'Produkt',
|
||||||
labelText: 'Produkt',
|
onChanged: (value) => setState(() => _selectedProductId = value),
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|||||||
Reference in New Issue
Block a user