/// Banner, list markers, and merge sheet for name duplicates. library; import 'package:flutter/material.dart'; import '../models/product.dart'; import '../services/product_repository.dart'; import '../utils/money.dart'; import '../utils/product_names.dart'; import 'product_image.dart'; const IconData kProductNameConflictIcon = Icons.merge_type; class ProductNameConflictBanner extends StatelessWidget { const ProductNameConflictBanner({ super.key, required this.issues, required this.onTap, }); final ProductNameIssues issues; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; final title = _bannerTitle(issues); final subtitle = _bannerSubtitle(issues); return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Material( color: scheme.errorContainer.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(12), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), child: Row( children: [ Icon(kProductNameConflictIcon, color: scheme.error), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: theme.textTheme.titleSmall), Text( subtitle, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall, ), ], ), ), Icon(Icons.chevron_right, color: scheme.onSurfaceVariant), ], ), ), ), ), ); } } String _bannerTitle(ProductNameIssues issues) { final count = issues.issueCount; if (count == 1 && issues.mergeGroups.length == 1) { return '1 duplicate product'; } if (count == 1 && issues.spellingVariants.length == 1) { return '1 product, multiple spellings'; } return '$count name issues'; } String _bannerSubtitle(ProductNameIssues issues) { final examples = []; for (final group in issues.mergeGroups.take(2)) { final store = group.supermarket == null ? '' : ' @ ${group.supermarket}'; examples.add( '${group.products.map((product) => product.name).join(' / ')}$store', ); } for (final variant in issues.spellingVariants.take(2)) { final store = variant.supermarket == null ? '' : ' @ ${variant.supermarket}'; examples.add('${variant.variants.join(' / ')}$store'); } return examples.join(', '); } class ProductNameConflictMarker extends StatelessWidget { const ProductNameConflictMarker({ super.key, this.onTap, this.compact = false, }); final VoidCallback? onTap; final bool compact; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final icon = Icon( kProductNameConflictIcon, size: compact ? 14 : 20, color: scheme.error, ); if (onTap == null) return icon; if (compact) { return Material( color: scheme.errorContainer, shape: const CircleBorder(), child: InkWell( onTap: onTap, customBorder: const CircleBorder(), child: Padding(padding: const EdgeInsets.all(2), child: icon), ), ); } return IconButton( tooltip: 'Resolve name issues', visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), onPressed: onTap, icon: icon, ); } } Future showProductNameConflictSheet( BuildContext context, { required ProductRepository repository, required ProductNameIssues issues, int? focusProductId, }) { if (issues.isEmpty) return Future.value(false); return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, useSafeArea: true, builder: (context) { final size = MediaQuery.sizeOf(context); return SizedBox( width: size.width, height: size.height * 0.85, child: _ProductNameConflictSheet( repository: repository, issues: issues, focusProductId: focusProductId, ), ); }, ).then((resolved) => resolved == true); } class _ProductNameConflictSheet extends StatefulWidget { const _ProductNameConflictSheet({ required this.repository, required this.issues, this.focusProductId, }); final ProductRepository repository; final ProductNameIssues issues; final int? focusProductId; @override State<_ProductNameConflictSheet> createState() => _ProductNameConflictSheetState(); } class _ProductNameConflictSheetState extends State<_ProductNameConflictSheet> { late List _mergeGroups; late List _spellingVariants; late Map _keepByMergeGroup; late Map _spellingChoiceByIndex; final Set _busyMergeGroups = {}; final Set _busySpellingGroups = {}; bool _resolvedAny = false; @override void initState() { super.initState(); _mergeGroups = [for (final group in widget.issues.mergeGroups) group]; _spellingVariants = [ for (final variant in widget.issues.spellingVariants) variant, ]; _rebuildChoices(); } void _rebuildChoices() { _keepByMergeGroup = { for (var i = 0; i < _mergeGroups.length; i++) i: _defaultKeep(_mergeGroups[i].products), }; _spellingChoiceByIndex = { for (var i = 0; i < _spellingVariants.length; i++) i: _spellingVariants[i].variants.first, }; } int _defaultKeep(List group) { final sorted = [...group] ..sort((a, b) { final seen = b.timesSeen.compareTo(a.timesSeen); if (seen != 0) return seen; return a.id.compareTo(b.id); }); return sorted.first.id; } bool get _isEmpty => _mergeGroups.isEmpty && _spellingVariants.isEmpty; int? _focusedMergeIndex() { final focus = widget.focusProductId; if (focus == null) return null; for (var i = 0; i < _mergeGroups.length; i++) { if (_mergeGroups[i].products.any((product) => product.id == focus)) { return i; } } return null; } int? _focusedSpellingIndex() { final focus = widget.focusProductId; if (focus == null) return null; for (var i = 0; i < _spellingVariants.length; i++) { if (_spellingVariants[i].product.id == focus) return i; } return null; } void _maybeClose() { if (_isEmpty && mounted) { Navigator.of(context).pop(true); } } Future _mergeGroup(int index) async { if (_busyMergeGroups.contains(index)) return; final group = _mergeGroups[index]; final keepId = _keepByMergeGroup[index]!; setState(() => _busyMergeGroups.add(index)); try { await widget.repository.mergeProducts( keepId: keepId, sourceIds: [for (final product in group.products) product.id], ); if (!mounted) return; setState(() { _resolvedAny = true; _busyMergeGroups.remove(index); _mergeGroups.removeAt(index); _rebuildChoices(); }); _maybeClose(); } catch (_) { if (!mounted) return; setState(() => _busyMergeGroups.remove(index)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not merge these products.')), ); } } Future _leaveMergeAsIs(int index) async { final group = _mergeGroups[index]; await widget.repository.dismissNameIssue(group.key); if (!mounted) return; setState(() { _resolvedAny = true; _mergeGroups.removeAt(index); _rebuildChoices(); }); _maybeClose(); } Future _applySpelling(int index) async { if (_busySpellingGroups.contains(index)) return; final variant = _spellingVariants[index]; final chosen = _spellingChoiceByIndex[index]!; setState(() => _busySpellingGroups.add(index)); try { await widget.repository.unifyProductSpelling( productId: variant.product.id, name: chosen, supermarket: variant.supermarket, ); if (!mounted) return; setState(() { _resolvedAny = true; _busySpellingGroups.remove(index); _spellingVariants.removeAt(index); _rebuildChoices(); }); _maybeClose(); } catch (_) { if (!mounted) return; setState(() => _busySpellingGroups.remove(index)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not update this spelling.')), ); } } Future _leaveSpellingAsIs(int index) async { final variant = _spellingVariants[index]; await widget.repository.dismissNameIssue(variant.key); if (!mounted) return; setState(() { _resolvedAny = true; _spellingVariants.removeAt(index); _rebuildChoices(); }); _maybeClose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final focusMergeIndex = _focusedMergeIndex(); final focusSpellingIndex = _focusedSpellingIndex(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Resolve name issues', style: theme.textTheme.titleLarge), const SizedBox(height: 4), Text( 'Same name at the same store. Pick which entry to keep, ' 'then merge or leave as separate products.', style: theme.textTheme.bodyMedium, ), ], ), ), const SizedBox(height: 12), Expanded( child: _isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( _resolvedAny ? 'All name issues resolved.' : 'No name issues found.', textAlign: TextAlign.center, style: theme.textTheme.bodyLarge, ), ), ) : ListView( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), children: [ if (_mergeGroups.isNotEmpty) ...[ Text( 'Separate products to merge', style: theme.textTheme.titleSmall, ), const SizedBox(height: 8), for (var i = 0; i < _mergeGroups.length; i++) ...[ if (i > 0) const SizedBox(height: 12), _MergeGroupCard( group: _mergeGroups[i], keepId: _keepByMergeGroup[i]!, busy: _busyMergeGroups.contains(i), highlighted: focusMergeIndex == i, onKeepChanged: (id) { setState(() => _keepByMergeGroup[i] = id); }, onMerge: () => _mergeGroup(i), onLeaveAsIs: () => _leaveMergeAsIs(i), ), ], ], if (_spellingVariants.isNotEmpty) ...[ if (_mergeGroups.isNotEmpty) const SizedBox(height: 20), Text( 'One product, multiple spellings', style: theme.textTheme.titleSmall, ), const SizedBox(height: 8), for (var i = 0; i < _spellingVariants.length; i++) ...[ if (i > 0) const SizedBox(height: 12), _SpellingVariantCard( variant: _spellingVariants[i], chosen: _spellingChoiceByIndex[i]!, busy: _busySpellingGroups.contains(i), highlighted: focusSpellingIndex == i, onChosen: (name) { setState(() => _spellingChoiceByIndex[i] = name); }, onApply: () => _applySpelling(i), onLeaveAsIs: () => _leaveSpellingAsIs(i), ), ], ], ], ), ), ], ); } } class _MergeGroupCard extends StatelessWidget { const _MergeGroupCard({ required this.group, required this.keepId, required this.busy, required this.highlighted, required this.onKeepChanged, required this.onMerge, required this.onLeaveAsIs, }); final ProductMergeGroup group; final int keepId; final bool busy; final bool highlighted; final ValueChanged onKeepChanged; final VoidCallback onMerge; final VoidCallback onLeaveAsIs; @override Widget build(BuildContext context) { final theme = Theme.of(context); final products = group.products; return Card( color: highlighted ? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35) : null, child: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Icon( kProductNameConflictIcon, size: 18, color: theme.colorScheme.tertiary, ), const SizedBox(width: 8), Expanded( child: Text( [ normalizeProductName(products.first.name), if (group.supermarket != null) group.supermarket!, ].join(' · '), style: theme.textTheme.titleSmall, ), ), ], ), const SizedBox(height: 8), for (final product in products) ListTile( contentPadding: EdgeInsets.zero, dense: true, leading: ProductImage( name: product.name, path: product.imagePath, size: 36, ), title: Text(product.name), subtitle: Text( [ formatEuro(product.lastPrice), product.timesSeen == 1 ? '1 purchase' : '${product.timesSeen} purchases', ].join(' · '), ), trailing: Icon( keepId == product.id ? Icons.check_circle : Icons.circle_outlined, color: keepId == product.id ? theme.colorScheme.primary : theme.colorScheme.outline, ), onTap: busy ? null : () => onKeepChanged(product.id), ), Wrap( alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, children: [ TextButton( onPressed: busy ? null : onLeaveAsIs, child: const Text('Leave as is'), ), FilledButton( onPressed: busy ? null : onMerge, child: busy ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Merge'), ), ], ), ], ), ), ); } } class _SpellingVariantCard extends StatelessWidget { const _SpellingVariantCard({ required this.variant, required this.chosen, required this.busy, required this.highlighted, required this.onChosen, required this.onApply, required this.onLeaveAsIs, }); final ProductSpellingVariant variant; final String chosen; final bool busy; final bool highlighted; final ValueChanged onChosen; final VoidCallback onApply; final VoidCallback onLeaveAsIs; @override Widget build(BuildContext context) { final theme = Theme.of(context); final product = variant.product; return Card( color: highlighted ? theme.colorScheme.tertiaryContainer.withValues(alpha: 0.35) : null, child: Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ListTile( contentPadding: EdgeInsets.zero, dense: true, leading: ProductImage( name: product.name, path: product.imagePath, size: 36, ), title: Text(product.name), subtitle: Text( [ if (variant.supermarket != null) variant.supermarket!, product.timesSeen == 1 ? '1 purchase' : '${product.timesSeen} purchases', ].join(' · '), ), ), const SizedBox(height: 4), Text('Recorded as:', style: theme.textTheme.labelLarge), const SizedBox(height: 4), for (final name in variant.variants) ListTile( contentPadding: EdgeInsets.zero, dense: true, title: Text(name), trailing: Icon( chosen == name ? Icons.check_circle : Icons.circle_outlined, color: chosen == name ? theme.colorScheme.primary : theme.colorScheme.outline, ), onTap: busy ? null : () => onChosen(name), ), Wrap( alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, children: [ TextButton( onPressed: busy ? null : onLeaveAsIs, child: const Text('Leave as is'), ), FilledButton( onPressed: busy ? null : onApply, child: Text(busy ? 'Saving…' : 'Use this spelling'), ), ], ), ], ), ), ); } }