feat: riscrivi app in Flutter
Riscrittura completa da Vue 3 + Capacitor a Flutter/Dart. Architettura feature-based (lib/features/): - meal_planner: modello MealPlan/DayPlan, provider, pagina e widget - converter: modello ConversionEntry, provider con ricerca, pagina - shopping_list: modello ShoppingItem, provider con deduplicazione, pagina e widget Core: - Material 3 con seed color #2d6a4f - NavigationBar + IndexedStack (preserva stato dei tab) - Portrait lock via SystemChrome - Persistenza con shared_preferences - 50+ alimenti × metodi cottura in assets/data/conversions.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
class ShoppingItem {
|
||||
final String id;
|
||||
final String name;
|
||||
final bool checked;
|
||||
|
||||
const ShoppingItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.checked = false,
|
||||
});
|
||||
|
||||
ShoppingItem copyWith({String? id, String? name, bool? checked}) =>
|
||||
ShoppingItem(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
checked: checked ?? this.checked,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'checked': checked,
|
||||
};
|
||||
|
||||
factory ShoppingItem.fromJson(Map<String, dynamic> json) => ShoppingItem(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
checked: json['checked'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/shopping_list_provider.dart';
|
||||
import '../widgets/shopping_item_tile.dart';
|
||||
import '../../../../shared/widgets/empty_state.dart';
|
||||
|
||||
class ShoppingListPage extends StatefulWidget {
|
||||
const ShoppingListPage({super.key});
|
||||
|
||||
@override
|
||||
State<ShoppingListPage> createState() => _ShoppingListPageState();
|
||||
}
|
||||
|
||||
class _ShoppingListPageState extends State<ShoppingListPage> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
context.read<ShoppingListProvider>().add(text);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
Future<void> _confirmClear() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Svuota lista'),
|
||||
content: const Text('Svuotare tutta la lista della spesa?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annulla'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(ctx).colorScheme.error,
|
||||
),
|
||||
child: const Text('Svuota'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true && mounted) {
|
||||
context.read<ShoppingListProvider>().clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final provider = context.watch<ShoppingListProvider>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 56, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Lista della spesa',
|
||||
style: theme.textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (provider.items.isNotEmpty)
|
||||
Text(
|
||||
'${provider.checkedItems.length} / ${provider.items.length} '
|
||||
'completat${provider.checkedItems.length == 1 ? 'o' : 'i'}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Add row
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration:
|
||||
const InputDecoration(hintText: 'Aggiungi elemento…'),
|
||||
onSubmitted: (_) => _add(),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _add,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(52, 52),
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: provider.items.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.shopping_cart_outlined,
|
||||
title: 'Lista vuota',
|
||||
subtitle: 'Aggiungi qualcosa con il campo qui sopra.',
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
children: [
|
||||
// Pending items
|
||||
if (provider.pendingItems.isNotEmpty)
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0;
|
||||
i < provider.pendingItems.length;
|
||||
i++) ...[
|
||||
if (i > 0)
|
||||
const Divider(
|
||||
height: 1, indent: 16, endIndent: 16),
|
||||
ShoppingItemTile(
|
||||
item: provider.pendingItems[i],
|
||||
onToggle: () => context
|
||||
.read<ShoppingListProvider>()
|
||||
.toggle(provider.pendingItems[i].id),
|
||||
onRemove: () => context
|
||||
.read<ShoppingListProvider>()
|
||||
.remove(provider.pendingItems[i].id),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Checked section
|
||||
if (provider.checkedItems.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'COMPLETATI (${provider.checkedItems.length})',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: theme.colorScheme.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: theme.colorScheme.outline
|
||||
.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0;
|
||||
i < provider.checkedItems.length;
|
||||
i++) ...[
|
||||
if (i > 0)
|
||||
const Divider(
|
||||
height: 1, indent: 16, endIndent: 16),
|
||||
ShoppingItemTile(
|
||||
item: provider.checkedItems[i],
|
||||
onToggle: () => context
|
||||
.read<ShoppingListProvider>()
|
||||
.toggle(provider.checkedItems[i].id),
|
||||
onRemove: () => context
|
||||
.read<ShoppingListProvider>()
|
||||
.remove(provider.checkedItems[i].id),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton(
|
||||
onPressed: _confirmClear,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.4),
|
||||
),
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
),
|
||||
child: const Text('Svuota lista'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/shopping_item.dart';
|
||||
|
||||
class ShoppingItemTile extends StatelessWidget {
|
||||
final ShoppingItem item;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const ShoppingItemTile({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onToggle,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.colorScheme.onSurface.withValues(alpha: 0.35);
|
||||
|
||||
return InkWell(
|
||||
onTap: onToggle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: item.checked,
|
||||
onChanged: (_) => onToggle(),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.name,
|
||||
style: item.checked
|
||||
? theme.textTheme.bodyMedium?.copyWith(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
color: muted,
|
||||
)
|
||||
: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
visualDensity: VisualDensity.compact,
|
||||
color: muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/shopping_item.dart';
|
||||
import '../../../shared/services/storage_service.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
|
||||
class ShoppingListProvider extends ChangeNotifier {
|
||||
List<ShoppingItem> _items = [];
|
||||
|
||||
List<ShoppingItem> get items => _items;
|
||||
List<ShoppingItem> get pendingItems =>
|
||||
_items.where((i) => !i.checked).toList();
|
||||
List<ShoppingItem> get checkedItems =>
|
||||
_items.where((i) => i.checked).toList();
|
||||
|
||||
Future<void> load() async {
|
||||
final json = await StorageService.load(kStorageKeyShopping);
|
||||
if (json != null) {
|
||||
try {
|
||||
final list = jsonDecode(json) as List;
|
||||
_items = list
|
||||
.map((e) => ShoppingItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
_items = [];
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final json = jsonEncode(_items.map((i) => i.toJson()).toList());
|
||||
await StorageService.save(kStorageKeyShopping, json);
|
||||
}
|
||||
|
||||
void add(String name) {
|
||||
final t = name.trim();
|
||||
if (t.isEmpty) return;
|
||||
_items = [
|
||||
..._items,
|
||||
ShoppingItem(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
name: t,
|
||||
),
|
||||
];
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void addAll(List<String> names) {
|
||||
final existing = _items.map((i) => i.name.toLowerCase()).toSet();
|
||||
final seen = <String>{};
|
||||
final toAdd = <ShoppingItem>[];
|
||||
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 (toAdd.isEmpty) return;
|
||||
_items = [..._items, ...toAdd];
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void toggle(String id) {
|
||||
_items = _items
|
||||
.map((i) => i.id == id ? i.copyWith(checked: !i.checked) : i)
|
||||
.toList();
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void remove(String id) {
|
||||
_items = _items.where((i) => i.id != id).toList();
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
_items = [];
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user