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 name;
final bool checked;
final int quantity;
const ShoppingItem({
required this.id,
required this.name,
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(
id: id ?? this.id,
name: name ?? this.name,
checked: checked ?? this.checked,
quantity: quantity ?? this.quantity,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'checked': checked,
'quantity': quantity,
};
factory ShoppingItem.fromJson(Map<String, dynamic> json) => ShoppingItem(
id: json['id'] as String,
name: json['name'] as String,
checked: json['checked'] as bool? ?? false,
quantity: json['quantity'] as int? ?? 1,
);
}
@@ -32,14 +32,33 @@ class ShoppingItemTile extends StatelessWidget {
),
),
Expanded(
child: Text(
item.name,
style: item.checked
? theme.textTheme.bodyMedium?.copyWith(
decoration: TextDecoration.lineThrough,
color: muted,
)
: theme.textTheme.bodyMedium,
child: Row(
children: [
Expanded(
child: Text(
item.name,
style: item.checked
? theme.textTheme.bodyMedium?.copyWith(
decoration: TextDecoration.lineThrough,
color: muted,
)
: 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(
@@ -49,16 +49,26 @@ class ShoppingListProvider extends ChangeNotifier {
void addAll(List<String> names) {
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) {
final key = name.toLowerCase().trim();
if (key.isNotEmpty && !existing.contains(key) && seen.add(key)) {
toAdd.add(ShoppingItem(
id: '${DateTime.now().millisecondsSinceEpoch}_${seen.length}',
name: name.trim(),
));
}
if (key.isEmpty) continue;
counts[key] = (counts[key] ?? 0) + 1;
canonical.putIfAbsent(key, () => 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;
_items = [..._items, ...toAdd];