feat: aggrega duplicati nella lista della spesa con quantità

Quando si genera la lista dal piano pasti, gli alimenti ripetuti in
più giorni o slot vengono aggregati in un unico elemento con contatore.
Es. zucchine lunedì + mercoledì → "zucchine (x2)".

- ShoppingItem: aggiunto campo quantity (default 1, persistito in JSON)
- ShoppingListProvider.addAll: conta le occorrenze per nome
  (case-insensitive) e crea un ShoppingItem con quantity = conteggio
- ShoppingItemTile: mostra "(xN)" colorato in primary quando quantity > 1,
  diventa muted insieme al nome quando l'elemento è completato

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 18:54:22 +02:00
parent cba83c9559
commit 404a5f44ac
3 changed files with 51 additions and 17 deletions
@@ -2,29 +2,34 @@ class ShoppingItem {
final String id; final String id;
final String name; final String name;
final bool checked; final bool checked;
final int quantity;
const ShoppingItem({ const ShoppingItem({
required this.id, required this.id,
required this.name, required this.name,
this.checked = false, this.checked = false,
this.quantity = 1,
}); });
ShoppingItem copyWith({String? id, String? name, bool? checked}) => ShoppingItem copyWith({String? id, String? name, bool? checked, int? quantity}) =>
ShoppingItem( ShoppingItem(
id: id ?? this.id, id: id ?? this.id,
name: name ?? this.name, name: name ?? this.name,
checked: checked ?? this.checked, checked: checked ?? this.checked,
quantity: quantity ?? this.quantity,
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
'name': name, 'name': name,
'checked': checked, 'checked': checked,
'quantity': quantity,
}; };
factory ShoppingItem.fromJson(Map<String, dynamic> json) => ShoppingItem( factory ShoppingItem.fromJson(Map<String, dynamic> json) => ShoppingItem(
id: json['id'] as String, id: json['id'] as String,
name: json['name'] as String, name: json['name'] as String,
checked: json['checked'] as bool? ?? false, checked: json['checked'] as bool? ?? false,
quantity: json['quantity'] as int? ?? 1,
); );
} }
@@ -31,6 +31,9 @@ class ShoppingItemTile extends StatelessWidget {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
Expanded(
child: Row(
children: [
Expanded( Expanded(
child: Text( child: Text(
item.name, item.name,
@@ -42,6 +45,22 @@ class ShoppingItemTile extends StatelessWidget {
: theme.textTheme.bodyMedium, : theme.textTheme.bodyMedium,
), ),
), ),
if (item.quantity > 1)
Padding(
padding: const EdgeInsets.only(left: 6),
child: Text(
'(x${item.quantity})',
style: theme.textTheme.bodySmall?.copyWith(
color: item.checked
? muted
: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
IconButton( IconButton(
onPressed: onRemove, onPressed: onRemove,
icon: const Icon(Icons.close, size: 16), icon: const Icon(Icons.close, size: 16),
@@ -49,16 +49,26 @@ class ShoppingListProvider extends ChangeNotifier {
void addAll(List<String> names) { void addAll(List<String> names) {
final existing = _items.map((i) => i.name.toLowerCase()).toSet(); final existing = _items.map((i) => i.name.toLowerCase()).toSet();
final seen = <String>{};
final toAdd = <ShoppingItem>[]; // Conta le occorrenze mantenendo il nome nella sua forma originale (prima occorrenza)
final counts = <String, int>{};
final canonical = <String, String>{};
for (final name in names) { for (final name in names) {
final key = name.toLowerCase().trim(); final key = name.toLowerCase().trim();
if (key.isNotEmpty && !existing.contains(key) && seen.add(key)) { if (key.isEmpty) continue;
toAdd.add(ShoppingItem( counts[key] = (counts[key] ?? 0) + 1;
id: '${DateTime.now().millisecondsSinceEpoch}_${seen.length}', canonical.putIfAbsent(key, () => name.trim());
name: name.trim(),
));
} }
final toAdd = <ShoppingItem>[];
var i = 0;
for (final entry in counts.entries) {
if (existing.contains(entry.key)) continue;
toAdd.add(ShoppingItem(
id: '${DateTime.now().millisecondsSinceEpoch}_${i++}',
name: canonical[entry.key]!,
quantity: entry.value,
));
} }
if (toAdd.isEmpty) return; if (toAdd.isEmpty) return;
_items = [..._items, ...toAdd]; _items = [..._items, ...toAdd];