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,81 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class DayPlan {
|
||||
final List<String> colazione;
|
||||
final List<String> pranzo;
|
||||
final List<String> cena;
|
||||
|
||||
DayPlan({
|
||||
List<String>? colazione,
|
||||
List<String>? pranzo,
|
||||
List<String>? cena,
|
||||
}) : colazione = colazione ?? [],
|
||||
pranzo = pranzo ?? [],
|
||||
cena = cena ?? [];
|
||||
|
||||
DayPlan copyWith({
|
||||
List<String>? colazione,
|
||||
List<String>? pranzo,
|
||||
List<String>? cena,
|
||||
}) =>
|
||||
DayPlan(
|
||||
colazione: colazione ?? List.of(this.colazione),
|
||||
pranzo: pranzo ?? List.of(this.pranzo),
|
||||
cena: cena ?? List.of(this.cena),
|
||||
);
|
||||
|
||||
List<String> slot(String name) => switch (name) {
|
||||
'colazione' => colazione,
|
||||
'pranzo' => pranzo,
|
||||
'cena' => cena,
|
||||
_ => const [],
|
||||
};
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'colazione': colazione,
|
||||
'pranzo': pranzo,
|
||||
'cena': cena,
|
||||
};
|
||||
|
||||
factory DayPlan.fromJson(Map<String, dynamic> json) => DayPlan(
|
||||
colazione: List<String>.from(json['colazione'] ?? []),
|
||||
pranzo: List<String>.from(json['pranzo'] ?? []),
|
||||
cena: List<String>.from(json['cena'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
class MealPlan {
|
||||
final Map<String, DayPlan> days;
|
||||
|
||||
const MealPlan(this.days);
|
||||
|
||||
factory MealPlan.empty(List<String> dayIds) =>
|
||||
MealPlan({for (final d in dayIds) d: DayPlan()});
|
||||
|
||||
MealPlan withUpdatedDay(String dayId, DayPlan updated) =>
|
||||
MealPlan({...days, dayId: updated});
|
||||
|
||||
bool get hasAnyMeal => days.values.any(
|
||||
(d) =>
|
||||
d.colazione.isNotEmpty ||
|
||||
d.pranzo.isNotEmpty ||
|
||||
d.cena.isNotEmpty,
|
||||
);
|
||||
|
||||
List<String> get allItems => days.values
|
||||
.expand((d) => [...d.colazione, ...d.pranzo, ...d.cena])
|
||||
.toList();
|
||||
|
||||
Map<String, dynamic> toJson() =>
|
||||
{for (final e in days.entries) e.key: e.value.toJson()};
|
||||
|
||||
factory MealPlan.fromJson(Map<String, dynamic> json) => MealPlan({
|
||||
for (final e in json.entries)
|
||||
e.key: DayPlan.fromJson(e.value as Map<String, dynamic>),
|
||||
});
|
||||
|
||||
String toJsonString() => jsonEncode(toJson());
|
||||
|
||||
factory MealPlan.fromJsonString(String s) =>
|
||||
MealPlan.fromJson(jsonDecode(s) as Map<String, dynamic>);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/meal_planner_provider.dart';
|
||||
import '../widgets/meal_card.dart';
|
||||
import '../../../shopping_list/providers/shopping_list_provider.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
|
||||
class MealPlannerPage extends StatelessWidget {
|
||||
final VoidCallback onGoShopping;
|
||||
|
||||
const MealPlannerPage({super.key, required this.onGoShopping});
|
||||
|
||||
String get _todayId {
|
||||
const ids = [
|
||||
'',
|
||||
'lunedi',
|
||||
'martedi',
|
||||
'mercoledi',
|
||||
'giovedi',
|
||||
'venerdi',
|
||||
'sabato',
|
||||
'domenica',
|
||||
];
|
||||
return ids[DateTime.now().weekday];
|
||||
}
|
||||
|
||||
String get _todayFormatted {
|
||||
final now = DateTime.now();
|
||||
const months = [
|
||||
'gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno',
|
||||
'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre',
|
||||
];
|
||||
const weekdays = [
|
||||
'', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica',
|
||||
];
|
||||
return 'Oggi, ${weekdays[now.weekday]} ${now.day} ${months[now.month - 1]}';
|
||||
}
|
||||
|
||||
Future<void> _confirmClear(BuildContext context) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Svuota piano'),
|
||||
content: const Text('Svuotare tutto il piano settimanale?'),
|
||||
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 && context.mounted) {
|
||||
context.read<MealPlannerProvider>().clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
void _generateShopping(BuildContext context) {
|
||||
final plan = context.read<MealPlannerProvider>().plan;
|
||||
context.read<ShoppingListProvider>().addAll(plan.allItems);
|
||||
onGoShopping();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final provider = context.watch<MealPlannerProvider>();
|
||||
final todayId = _todayId;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 56, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Piano Pasti',
|
||||
style: theme.textTheme.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
_todayFormatted,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
children: [
|
||||
for (final dayId in kDayIds)
|
||||
MealCard(
|
||||
key: ValueKey(dayId),
|
||||
dayId: dayId,
|
||||
dayLabel: kDayLabels[dayId]!,
|
||||
dayPlan: provider.plan.days[dayId] ??
|
||||
(throw StateError('Day $dayId not found')),
|
||||
initiallyExpanded: dayId == todayId,
|
||||
onAdd: (slot, text) =>
|
||||
context.read<MealPlannerProvider>().addItem(dayId, slot, text),
|
||||
onRemove: (slot, index) =>
|
||||
context.read<MealPlannerProvider>().removeItem(dayId, slot, index),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _generateShopping(context),
|
||||
icon: const Icon(Icons.shopping_cart_outlined),
|
||||
label: const Text('Genera lista della spesa'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 52),
|
||||
),
|
||||
),
|
||||
if (provider.plan.hasAnyMeal) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => _confirmClear(context),
|
||||
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 piano'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../models/meal_plan.dart';
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
|
||||
class MealCard extends StatefulWidget {
|
||||
final String dayId;
|
||||
final String dayLabel;
|
||||
final DayPlan dayPlan;
|
||||
final bool initiallyExpanded;
|
||||
final void Function(String slot, String text) onAdd;
|
||||
final void Function(String slot, int index) onRemove;
|
||||
|
||||
const MealCard({
|
||||
super.key,
|
||||
required this.dayId,
|
||||
required this.dayLabel,
|
||||
required this.dayPlan,
|
||||
required this.onAdd,
|
||||
required this.onRemove,
|
||||
this.initiallyExpanded = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MealCard> createState() => _MealCardState();
|
||||
}
|
||||
|
||||
class _MealCardState extends State<MealCard> {
|
||||
late final Map<String, TextEditingController> _controllers = {
|
||||
for (final slot in kMealSlots) slot: TextEditingController(),
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _controllers.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add(String slot) {
|
||||
final text = _controllers[slot]!.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
widget.onAdd(slot, text);
|
||||
_controllers[slot]!.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final totalItems = widget.dayPlan.colazione.length +
|
||||
widget.dayPlan.pranzo.length +
|
||||
widget.dayPlan.cena.length;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: widget.initiallyExpanded,
|
||||
title: Text(
|
||||
widget.dayLabel,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: totalItems > 0
|
||||
? Text('$totalItems voc${totalItems == 1 ? 'e' : 'i'}')
|
||||
: null,
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
for (final slot in kMealSlots)
|
||||
_SlotSection(
|
||||
label: kMealSlotLabels[slot]!,
|
||||
items: widget.dayPlan.slot(slot),
|
||||
controller: _controllers[slot]!,
|
||||
onAdd: () => _add(slot),
|
||||
onRemove: (i) => widget.onRemove(slot, i),
|
||||
isLast: slot == kMealSlots.last,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlotSection extends StatelessWidget {
|
||||
final String label;
|
||||
final List<String> items;
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onAdd;
|
||||
final void Function(int) onRemove;
|
||||
final bool isLast;
|
||||
|
||||
const _SlotSection({
|
||||
required this.label,
|
||||
required this.items,
|
||||
required this.controller,
|
||||
required this.onAdd,
|
||||
required this.onRemove,
|
||||
required this.isLast,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 6),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (int i = 0; i < items.length; i++)
|
||||
_ItemRow(text: items[i], onRemove: () => onRemove(i)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Aggiungi a ${label.toLowerCase()}…',
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => onAdd(),
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: FilledButton(
|
||||
onPressed: onAdd,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.add, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast) const Divider(height: 1),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ItemRow extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _ItemRow({required this.text, required this.onRemove});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.circle,
|
||||
size: 5,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(text, style: theme.textTheme.bodyMedium)),
|
||||
IconButton(
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
visualDensity: VisualDensity.compact,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.35),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/meal_plan.dart';
|
||||
import '../../../shared/services/storage_service.dart';
|
||||
import '../../../core/constants/app_constants.dart';
|
||||
|
||||
class MealPlannerProvider extends ChangeNotifier {
|
||||
MealPlan _plan = MealPlan.empty(kDayIds);
|
||||
|
||||
MealPlan get plan => _plan;
|
||||
|
||||
Future<void> load() async {
|
||||
final json = await StorageService.load(kStorageKeyMeals);
|
||||
if (json != null) {
|
||||
try {
|
||||
_plan = MealPlan.fromJsonString(json);
|
||||
} catch (_) {
|
||||
_plan = MealPlan.empty(kDayIds);
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _save() =>
|
||||
StorageService.save(kStorageKeyMeals, _plan.toJsonString());
|
||||
|
||||
void addItem(String dayId, String slot, String text) {
|
||||
final t = text.trim();
|
||||
if (t.isEmpty) return;
|
||||
final day = _plan.days[dayId]!;
|
||||
final updated = switch (slot) {
|
||||
'colazione' => day.copyWith(colazione: [...day.colazione, t]),
|
||||
'pranzo' => day.copyWith(pranzo: [...day.pranzo, t]),
|
||||
'cena' => day.copyWith(cena: [...day.cena, t]),
|
||||
_ => day,
|
||||
};
|
||||
_plan = _plan.withUpdatedDay(dayId, updated);
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void removeItem(String dayId, String slot, int index) {
|
||||
final day = _plan.days[dayId]!;
|
||||
final updated = switch (slot) {
|
||||
'colazione' =>
|
||||
day.copyWith(colazione: List.of(day.colazione)..removeAt(index)),
|
||||
'pranzo' =>
|
||||
day.copyWith(pranzo: List.of(day.pranzo)..removeAt(index)),
|
||||
'cena' =>
|
||||
day.copyWith(cena: List.of(day.cena)..removeAt(index)),
|
||||
_ => day,
|
||||
};
|
||||
_plan = _plan.withUpdatedDay(dayId, updated);
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
_plan = MealPlan.empty(kDayIds);
|
||||
notifyListeners();
|
||||
_save();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user