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 createState() => _ProductListViewState(); } class _ProductListViewState extends State { String _query = ''; /// Null shows every category. String? _category; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().ensureLoaded(); }); } /// Products per category label, in label order, after search and filter. Map> _groupedProducts( List products, String? selectedCategory, AppLocalizations l10n, ) { final query = _query.trim().toLowerCase(); final groups = >{}; 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(); 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!], ], ), ), ); } }