feat: reformat some pages

This commit is contained in:
2026-10-26 23:15:05 +01:00
parent 65e5e286b0
commit 08f4847800
17 changed files with 812 additions and 198 deletions
+320 -45
View File
@@ -6,6 +6,7 @@ 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';
@@ -18,6 +19,11 @@ class ProductListView extends StatefulWidget {
}
class _ProductListViewState extends State<ProductListView> {
String _query = '';
/// Null shows every category.
String? _category;
@override
void initState() {
super.initState();
@@ -27,6 +33,31 @@ class _ProductListViewState extends State<ProductListView> {
});
}
/// 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>();
@@ -53,62 +84,306 @@ class _ProductListViewState extends State<ProductListView> {
}
if (viewModel.loadError != null) {
return Center(child: Text(l10n.errorText(viewModel.loadError)));
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 Center(child: Text(l10n.noProductsYet));
return _MessageState(
icon: Icons.inventory_2_outlined,
message: l10n.noProductsYet,
detail: l10n.addFirstProduct,
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: viewModel.products.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final product = viewModel.products[index];
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 Card(
child: ListTile(
leading: SizedBox(
width: 56,
height: 56,
child: Center(
child:
product.imagePath != null &&
product.imagePath!.isNotEmpty
? Image.file(
File(product.imagePath!),
fit: BoxFit.contain,
cacheWidth: 112,
errorBuilder: (context, error, stackTrace) =>
const Icon(
Icons.image_not_supported_outlined,
),
)
: const Icon(Icons.image_not_supported_outlined),
),
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,
),
title: Text(product.name),
subtitle: Text(
product.infiniteStock
? l10n.productInfiniteStockSummary(
l10n.categoryLabel(product.category),
product.priceInCents.asEuro,
)
: l10n.productStockSummary(
l10n.categoryLabel(product.category),
product.priceInCents.asEuro,
product.stockQuantity,
),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/products/${product.id}/edit'),
),
);
},
),
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!],
],
),
),
);
}
}