390 lines
12 KiB
Dart
390 lines
12 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../l10n/app_localizations.dart';
|
|
import '../l10n/app_localizations_helpers.dart';
|
|
import '../models/product.dart';
|
|
import '../utils/money.dart';
|
|
import '../utils/navigation.dart';
|
|
import '../viewmodels/product_list_view_model.dart';
|
|
|
|
class ProductListView extends StatefulWidget {
|
|
const ProductListView({super.key});
|
|
|
|
@override
|
|
State<ProductListView> createState() => _ProductListViewState();
|
|
}
|
|
|
|
class _ProductListViewState extends State<ProductListView> {
|
|
String _query = '';
|
|
|
|
/// Null shows every category.
|
|
String? _category;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
context.read<ProductListViewModel>().ensureLoaded();
|
|
});
|
|
}
|
|
|
|
/// Products per category label, in label order, after search and filter.
|
|
Map<String, List<Product>> _groupedProducts(
|
|
List<Product> products,
|
|
String? selectedCategory,
|
|
AppLocalizations l10n,
|
|
) {
|
|
final query = _query.trim().toLowerCase();
|
|
final groups = <String, List<Product>>{};
|
|
|
|
for (final product in products) {
|
|
final category = l10n.categoryLabel(product.category.trim());
|
|
if (selectedCategory != null && category != selectedCategory) {
|
|
continue;
|
|
}
|
|
if (query.isNotEmpty && !product.name.toLowerCase().contains(query)) {
|
|
continue;
|
|
}
|
|
|
|
groups.putIfAbsent(category, () => []).add(product);
|
|
}
|
|
|
|
final sortedKeys = groups.keys.toList()..sort();
|
|
return {for (final key in sortedKeys) key: groups[key]!};
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final viewModel = context.watch<ProductListViewModel>();
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
leading: IconButton(
|
|
onPressed: () => context.popOrGo('/admin'),
|
|
icon: const Icon(Icons.arrow_back),
|
|
),
|
|
title: Text(l10n.products),
|
|
),
|
|
resizeToAvoidBottomInset: false,
|
|
floatingActionButton: FloatingActionButton.extended(
|
|
onPressed: () => context.push('/products/new'),
|
|
icon: const Icon(Icons.add),
|
|
label: Text(l10n.addProduct),
|
|
),
|
|
body: Builder(
|
|
builder: (context) {
|
|
if (viewModel.isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (viewModel.loadError != null) {
|
|
return _MessageState(
|
|
icon: Icons.error_outline_rounded,
|
|
isError: true,
|
|
message: l10n.errorText(viewModel.loadError),
|
|
action: FilledButton.icon(
|
|
onPressed: viewModel.retry,
|
|
icon: const Icon(Icons.refresh),
|
|
label: Text(l10n.retry),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (viewModel.products.isEmpty) {
|
|
return _MessageState(
|
|
icon: Icons.inventory_2_outlined,
|
|
message: l10n.noProductsYet,
|
|
detail: l10n.addFirstProduct,
|
|
);
|
|
}
|
|
|
|
final categories = {
|
|
for (final product in viewModel.products)
|
|
l10n.categoryLabel(product.category.trim()),
|
|
}.toList()..sort();
|
|
// A category disappears when its last product moves out of it.
|
|
final selectedCategory = categories.contains(_category)
|
|
? _category
|
|
: null;
|
|
final groups = _groupedProducts(
|
|
viewModel.products,
|
|
selectedCategory,
|
|
l10n,
|
|
);
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: TextField(
|
|
onChanged: (value) => setState(() => _query = value),
|
|
decoration: InputDecoration(
|
|
hintText: l10n.searchProducts,
|
|
prefixIcon: const Icon(Icons.search_rounded, size: 20),
|
|
isDense: true,
|
|
),
|
|
),
|
|
),
|
|
if (categories.length > 1)
|
|
SizedBox(
|
|
height: 52,
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
children: [
|
|
for (final category in [null, ...categories])
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: ChoiceChip(
|
|
label: Text(category ?? l10n.allCategories),
|
|
selected: selectedCategory == category,
|
|
onSelected: (_) =>
|
|
setState(() => _category = category),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: groups.isEmpty
|
|
? _MessageState(
|
|
icon: Icons.search_off_rounded,
|
|
message: l10n.noProductsMatch,
|
|
)
|
|
: ListView(
|
|
// Leaves room to scroll the last item past the button.
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 96),
|
|
children: [
|
|
for (final MapEntry(key: category, value: products)
|
|
in groups.entries) ...[
|
|
_CategoryHeader(
|
|
title: category,
|
|
count: products.length,
|
|
),
|
|
for (final product in products)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: _ProductRow(product: product),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CategoryHeader extends StatelessWidget {
|
|
final String title;
|
|
final int count;
|
|
|
|
const _CategoryHeader({required this.title, required this.count});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(4, 12, 4, 8),
|
|
child: Text(
|
|
'${title.toUpperCase()} · $count',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.5,
|
|
color: scheme.onSurface.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProductRow extends StatelessWidget {
|
|
final Product product;
|
|
|
|
const _ProductRow({required this.product});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final imagePath = product.imagePath;
|
|
|
|
final (String, Color, Color)? badge = !product.active
|
|
? (l10n.productHidden, scheme.surfaceContainerHighest, scheme.onSurface)
|
|
: product.isOutOfStock
|
|
? (l10n.outOfStock, scheme.errorContainer, scheme.onErrorContainer)
|
|
: product.isLowStock
|
|
? (l10n.lowStock, scheme.tertiaryContainer, scheme.onTertiaryContainer)
|
|
: null;
|
|
|
|
return Card(
|
|
margin: EdgeInsets.zero,
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: () => context.push('/products/${product.id}/edit'),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(10),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: scheme.onSurface.withValues(alpha: 0.04),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: imagePath != null && imagePath.isNotEmpty
|
|
? Image.file(
|
|
File(imagePath),
|
|
fit: BoxFit.contain,
|
|
cacheWidth: 112,
|
|
errorBuilder: (context, error, stackTrace) =>
|
|
const _ImagePlaceholder(),
|
|
)
|
|
: const _ImagePlaceholder(),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Opacity(
|
|
opacity: product.active ? 1 : 0.5,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
product.name,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
product.infiniteStock
|
|
? '∞'
|
|
: l10n.productStockCount(product.stockQuantity),
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: scheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (badge case (final label, final background, final foreground))
|
|
Container(
|
|
margin: const EdgeInsets.only(right: 12),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 3,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: foreground,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
product.priceInCents.asEuro,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: scheme.onSurface.withValues(alpha: 0.3),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ImagePlaceholder extends StatelessWidget {
|
|
const _ImagePlaceholder();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Icon(
|
|
Icons.local_bar_outlined,
|
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MessageState extends StatelessWidget {
|
|
final IconData icon;
|
|
final String message;
|
|
final String? detail;
|
|
final Widget? action;
|
|
final bool isError;
|
|
|
|
const _MessageState({
|
|
required this.icon,
|
|
required this.message,
|
|
this.detail,
|
|
this.action,
|
|
this.isError = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 48,
|
|
color: isError
|
|
? scheme.error
|
|
: scheme.onSurface.withValues(alpha: 0.35),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
if (detail != null) ...[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
detail!,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: scheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
],
|
|
if (action != null) ...[const SizedBox(height: 20), action!],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|