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
@@ -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),
);
},
),
),
],
);
},
);
}
}