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 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"cous cous precotto": { "bollitura": { "yield": 2.25 } },
|
||||||
|
"farro perlato": { "bollitura": { "yield": 2.28 } },
|
||||||
|
"orzo perlato": { "bollitura": { "yield": 2.67 } },
|
||||||
|
"quinoa": { "bollitura": { "yield": 3.12 } },
|
||||||
|
"riso basmati": { "bollitura": { "yield": 3.00 } },
|
||||||
|
"riso brillato": { "bollitura": { "yield": 2.60 } },
|
||||||
|
"riso parboiled": { "bollitura": { "yield": 2.36 } },
|
||||||
|
"riso venere": { "bollitura": { "yield": 2.10 } },
|
||||||
|
"pasta all'uovo fresca": { "bollitura": { "yield": 1.36 } },
|
||||||
|
"pasta all'uovo secca": { "bollitura": { "yield": 2.99 } },
|
||||||
|
"pasta semola corta": { "bollitura": { "yield": 1.88 } },
|
||||||
|
"pasta semola lunga": { "bollitura": { "yield": 2.10 } },
|
||||||
|
"gnocchi di patate": { "bollitura": { "yield": 1.06 } },
|
||||||
|
|
||||||
|
"ceci secchi": { "bollitura": { "yield": 2.90 } },
|
||||||
|
"fagioli secchi": { "bollitura": { "yield": 2.30 } },
|
||||||
|
"lenticchie secche": { "bollitura": { "yield": 2.47 } },
|
||||||
|
|
||||||
|
"carote": { "bollitura": { "yield": 0.87 }, "forno": { "yield": 0.80 }, "friggitrice ad aria": { "yield": 0.74 } },
|
||||||
|
"carote (fettine)": { "padella": { "yield": 0.38 }, "forno": { "yield": 0.72 }, "friggitrice ad aria": { "yield": 0.66 } },
|
||||||
|
"cipolle": { "bollitura": { "yield": 0.73 }, "forno": { "yield": 0.65 }, "friggitrice ad aria": { "yield": 0.60 } },
|
||||||
|
"cipolle (cubetti)": { "padella": { "yield": 0.44 }, "forno": { "yield": 0.50 }, "friggitrice ad aria": { "yield": 0.46 } },
|
||||||
|
"melanzane": { "forno": { "yield": 0.42 }, "friggitrice ad aria": { "yield": 0.37 } },
|
||||||
|
"melanzane (cubetti)": { "padella": { "yield": 0.80 }, "forno": { "yield": 0.75 }, "friggitrice ad aria": { "yield": 0.69 } },
|
||||||
|
"patate": { "bollitura": { "yield": 0.94 }, "forno": { "yield": 0.90 }, "friggitrice ad aria": { "yield": 0.90 } },
|
||||||
|
"patate (spicchi)": { "padella": { "yield": 0.64 }, "forno": { "yield": 0.63 }, "friggitrice ad aria": { "yield": 0.60 } },
|
||||||
|
"peperoni": { "padella": { "yield": 0.60 }, "forno": { "yield": 0.96 }, "friggitrice ad aria": { "yield": 0.88 } },
|
||||||
|
"verdure miste": { "padella": { "yield": 0.75 }, "forno": { "yield": 0.78 }, "friggitrice ad aria": { "yield": 0.60 } },
|
||||||
|
"zucchine": { "bollitura": { "yield": 0.90 }, "padella": { "yield": 0.82 }, "forno": { "yield": 0.86 }, "friggitrice ad aria": { "yield": 0.83 } },
|
||||||
|
"zucchine (fettine)": { "padella": { "yield": 0.76 }, "forno": { "yield": 0.72 }, "friggitrice ad aria": { "yield": 0.66 } },
|
||||||
|
"spinaci": { "bollitura": { "yield": 0.85 }, "padella": { "yield": 0.85 } },
|
||||||
|
"bieta": { "bollitura": { "yield": 0.83 }, "padella": { "yield": 0.80 } },
|
||||||
|
"broccoli": { "bollitura": { "yield": 0.97 }, "padella": { "yield": 0.89 }, "forno": { "yield": 0.90 }, "friggitrice ad aria": { "yield": 0.83 } },
|
||||||
|
"cavolfiore": { "bollitura": { "yield": 0.97 }, "padella": { "yield": 0.89 }, "forno": { "yield": 0.90 }, "friggitrice ad aria": { "yield": 0.83 } },
|
||||||
|
"fagiolini": { "bollitura": { "yield": 0.95 }, "padella": { "yield": 0.86 }, "forno": { "yield": 0.88 }, "friggitrice ad aria": { "yield": 0.81 } },
|
||||||
|
"asparagi": { "bollitura": { "yield": 0.97 }, "padella": { "yield": 0.88 }, "forno": { "yield": 0.90 }, "friggitrice ad aria": { "yield": 0.83 } },
|
||||||
|
"carciofi": { "bollitura": { "yield": 0.75 }, "padella": { "yield": 0.66 }, "forno": { "yield": 0.68 }, "friggitrice ad aria": { "yield": 0.63 } },
|
||||||
|
"finocchi": { "bollitura": { "yield": 0.88 }, "padella": { "yield": 0.75 }, "forno": { "yield": 0.78 }, "friggitrice ad aria": { "yield": 0.72 } },
|
||||||
|
"porri": { "bollitura": { "yield": 1.00 }, "padella": { "yield": 0.80 }, "forno": { "yield": 0.82 } },
|
||||||
|
"verza": { "bollitura": { "yield": 1.00 }, "padella": { "yield": 0.88 }, "forno": { "yield": 0.90 } },
|
||||||
|
"cavolo cappuccio": { "bollitura": { "yield": 1.00 }, "padella": { "yield": 0.92 }, "forno": { "yield": 0.88 } },
|
||||||
|
"cicoria coltivata": { "bollitura": { "yield": 0.80 }, "padella": { "yield": 0.75 } },
|
||||||
|
"cicoria di campo": { "bollitura": { "yield": 1.00 }, "padella": { "yield": 0.88 } },
|
||||||
|
"cavolini di bruxelles": { "bollitura": { "yield": 0.89 }, "padella": { "yield": 0.82 }, "forno": { "yield": 0.82 }, "friggitrice ad aria": { "yield": 0.75 } },
|
||||||
|
"rape": { "bollitura": { "yield": 0.92 }, "padella": { "yield": 0.83 }, "forno": { "yield": 0.85 }, "friggitrice ad aria": { "yield": 0.78 } },
|
||||||
|
"agretti": { "bollitura": { "yield": 0.88 }, "padella": { "yield": 0.80 } },
|
||||||
|
|
||||||
|
"hamburger": { "padella": { "yield": 0.90 }, "friggitrice ad aria": { "yield": 0.85 } },
|
||||||
|
"pollo petto": { "bollitura": { "yield": 0.90 }, "padella": { "yield": 0.83 }, "forno": { "yield": 0.67 }, "friggitrice ad aria": { "yield": 0.63 } },
|
||||||
|
"tacchino fesa": { "bollitura": { "yield": 0.94 }, "padella": { "yield": 0.85 }, "forno": { "yield": 0.69 }, "friggitrice ad aria": { "yield": 0.65 } },
|
||||||
|
"vitello magro": { "friggitrice ad aria": { "yield": 0.51 } },
|
||||||
|
"frittata semplice": { "padella": { "yield": 0.87 } },
|
||||||
|
"uovo al tegamino": { "padella": { "yield": 0.90 } },
|
||||||
|
|
||||||
|
"merluzzo": { "bollitura": { "yield": 0.86 }, "forno": { "yield": 0.70 }, "friggitrice ad aria": { "yield": 0.66 } },
|
||||||
|
"sogliola": { "friggitrice ad aria": { "yield": 0.66 } },
|
||||||
|
"spigola": { "bollitura": { "yield": 0.86 }, "forno": { "yield": 0.75 }, "friggitrice ad aria": { "yield": 0.71 } },
|
||||||
|
"tonno": { "padella": { "yield": 0.78 }, "forno": { "yield": 0.74 }, "friggitrice ad aria": { "yield": 0.70 } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'core/theme/app_theme.dart';
|
||||||
|
import 'features/meal_planner/presentation/pages/meal_planner_page.dart';
|
||||||
|
import 'features/converter/presentation/pages/converter_page.dart';
|
||||||
|
import 'features/shopping_list/presentation/pages/shopping_list_page.dart';
|
||||||
|
|
||||||
|
class BitePlanApp extends StatelessWidget {
|
||||||
|
const BitePlanApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'BitePlan',
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: const _MainScaffold(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainScaffold extends StatefulWidget {
|
||||||
|
const _MainScaffold();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_MainScaffold> createState() => _MainScaffoldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainScaffoldState extends State<_MainScaffold> {
|
||||||
|
int _currentIndex = 0;
|
||||||
|
late final List<Widget> _pages;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_pages = [
|
||||||
|
MealPlannerPage(
|
||||||
|
onGoShopping: () => setState(() => _currentIndex = 2),
|
||||||
|
),
|
||||||
|
const ConverterPage(),
|
||||||
|
const ShoppingListPage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: IndexedStack(
|
||||||
|
index: _currentIndex,
|
||||||
|
children: _pages,
|
||||||
|
),
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: _currentIndex,
|
||||||
|
onDestinationSelected: (i) => setState(() => _currentIndex = i),
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.restaurant_menu_outlined),
|
||||||
|
selectedIcon: Icon(Icons.restaurant_menu),
|
||||||
|
label: 'Pasti',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.swap_horiz_outlined),
|
||||||
|
selectedIcon: Icon(Icons.swap_horiz),
|
||||||
|
label: 'Converti',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.shopping_cart_outlined),
|
||||||
|
selectedIcon: Icon(Icons.shopping_cart),
|
||||||
|
label: 'Spesa',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const List<String> kDayIds = [
|
||||||
|
'lunedi',
|
||||||
|
'martedi',
|
||||||
|
'mercoledi',
|
||||||
|
'giovedi',
|
||||||
|
'venerdi',
|
||||||
|
'sabato',
|
||||||
|
'domenica',
|
||||||
|
];
|
||||||
|
|
||||||
|
const Map<String, String> kDayLabels = {
|
||||||
|
'lunedi': 'Lunedì',
|
||||||
|
'martedi': 'Martedì',
|
||||||
|
'mercoledi': 'Mercoledì',
|
||||||
|
'giovedi': 'Giovedì',
|
||||||
|
'venerdi': 'Venerdì',
|
||||||
|
'sabato': 'Sabato',
|
||||||
|
'domenica': 'Domenica',
|
||||||
|
};
|
||||||
|
|
||||||
|
const List<String> kMealSlots = ['colazione', 'pranzo', 'cena'];
|
||||||
|
|
||||||
|
const Map<String, String> kMealSlotLabels = {
|
||||||
|
'colazione': 'Colazione',
|
||||||
|
'pranzo': 'Pranzo',
|
||||||
|
'cena': 'Cena',
|
||||||
|
};
|
||||||
|
|
||||||
|
const String kStorageKeyMeals = 'meals';
|
||||||
|
const String kStorageKeyShopping = 'shopping_list';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AppTheme {
|
||||||
|
static const _seed = Color(0xFF2d6a4f);
|
||||||
|
|
||||||
|
static ThemeData light() {
|
||||||
|
final scheme = ColorScheme.fromSeed(seedColor: _seed);
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: scheme,
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
class ConversionEntry {
|
||||||
|
final String food;
|
||||||
|
final String method;
|
||||||
|
final double yieldFactor;
|
||||||
|
|
||||||
|
const ConversionEntry({
|
||||||
|
required this.food,
|
||||||
|
required this.method,
|
||||||
|
required this.yieldFactor,
|
||||||
|
});
|
||||||
|
|
||||||
|
double rawToCooked(double raw) => raw * yieldFactor;
|
||||||
|
double cookedToRaw(double cooked) => cooked / yieldFactor;
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../models/conversion_entry.dart';
|
||||||
|
import '../../providers/converter_provider.dart';
|
||||||
|
import '../../../../shared/widgets/empty_state.dart';
|
||||||
|
|
||||||
|
class ConverterPage extends StatefulWidget {
|
||||||
|
const ConverterPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ConverterPage> createState() => _ConverterPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConverterPageState extends State<ConverterPage> {
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
final _gramsController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
_gramsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelect(ConversionEntry entry) {
|
||||||
|
_searchController.clear();
|
||||||
|
_gramsController.clear();
|
||||||
|
context.read<ConverterProvider>().select(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onReset() {
|
||||||
|
_gramsController.clear();
|
||||||
|
context.read<ConverterProvider>().reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSwap() {
|
||||||
|
_gramsController.clear();
|
||||||
|
context.read<ConverterProvider>().swapDirection();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cap(String s) =>
|
||||||
|
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final provider = context.watch<ConverterProvider>();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 56, 16, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Convertitore',
|
||||||
|
style: theme.textTheme.headlineSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Calcola il peso cotto dal crudo e viceversa',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
children: [
|
||||||
|
if (provider.selected == null) ...[
|
||||||
|
TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Cerca alimento…',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
context.read<ConverterProvider>().search('');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {});
|
||||||
|
context.read<ConverterProvider>().search(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (provider.results.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < provider.results.length; i++) ...[
|
||||||
|
if (i > 0) const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
_cap(provider.results[i].food),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
_cap(provider.results[i].method),
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onSurface
|
||||||
|
.withValues(alpha: 0.55),
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () => _onSelect(provider.results[i]),
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (provider.results.isEmpty &&
|
||||||
|
_searchController.text.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 48),
|
||||||
|
child: EmptyState(
|
||||||
|
icon: Icons.search,
|
||||||
|
title: 'Cerca un alimento per iniziare',
|
||||||
|
subtitle: 'es. pollo, riso, zucchine',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (provider.results.isEmpty &&
|
||||||
|
_searchController.text.isNotEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 48),
|
||||||
|
child: EmptyState(
|
||||||
|
icon: Icons.search_off,
|
||||||
|
title: 'Nessun risultato',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (provider.selected != null)
|
||||||
|
_ConverterCard(
|
||||||
|
provider: provider,
|
||||||
|
gramsController: _gramsController,
|
||||||
|
onReset: _onReset,
|
||||||
|
onSwap: _onSwap,
|
||||||
|
onGramsChanged: (v) => context
|
||||||
|
.read<ConverterProvider>()
|
||||||
|
.setGrams(double.tryParse(v)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConverterCard extends StatelessWidget {
|
||||||
|
final ConverterProvider provider;
|
||||||
|
final TextEditingController gramsController;
|
||||||
|
final VoidCallback onReset;
|
||||||
|
final VoidCallback onSwap;
|
||||||
|
final void Function(String) onGramsChanged;
|
||||||
|
|
||||||
|
const _ConverterCard({
|
||||||
|
required this.provider,
|
||||||
|
required this.gramsController,
|
||||||
|
required this.onReset,
|
||||||
|
required this.onSwap,
|
||||||
|
required this.onGramsChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
String _cap(String s) =>
|
||||||
|
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final selected = provider.selected!;
|
||||||
|
final result = provider.result;
|
||||||
|
final isRtC = provider.rawToCooked;
|
||||||
|
final muted = theme.colorScheme.onSurface.withValues(alpha: 0.5);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header row
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_cap(selected.food),
|
||||||
|
style: theme.textTheme.titleSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Text('·',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.outline, fontSize: 16)),
|
||||||
|
Text(
|
||||||
|
_cap(selected.method),
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: onReset, child: const Text('Cambia')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
|
||||||
|
// Calculator
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
// Input
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
isRtC ? 'CRUDO' : 'COTTO',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
color: muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: gramsController,
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(
|
||||||
|
decimal: true),
|
||||||
|
style: theme.textTheme.headlineMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '0',
|
||||||
|
border: UnderlineInputBorder(),
|
||||||
|
enabledBorder: UnderlineInputBorder(),
|
||||||
|
focusedBorder: UnderlineInputBorder(),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
onChanged: onGramsChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text('g',
|
||||||
|
style: theme.textTheme.titleMedium
|
||||||
|
?.copyWith(color: muted)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Swap button
|
||||||
|
Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
child: IconButton.outlined(
|
||||||
|
onPressed: onSwap,
|
||||||
|
icon: const Icon(Icons.swap_horiz),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
minimumSize: const Size(44, 44)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Output
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
isRtC ? 'COTTO' : 'CRUDO',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
color: muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: theme.colorScheme.outline
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
result != null
|
||||||
|
? _formatResult(result)
|
||||||
|
: '—',
|
||||||
|
style: result != null
|
||||||
|
? theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (result != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text('g',
|
||||||
|
style: theme.textTheme.titleMedium
|
||||||
|
?.copyWith(color: muted)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
if (result != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'fattore di resa ${selected.yieldFactor} · '
|
||||||
|
'${isRtC ? 'da mangiare cotti' : 'peso crudo equivalente'}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: muted),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatResult(double v) =>
|
||||||
|
v == v.truncateToDouble() ? v.toInt().toString() : v.toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../models/conversion_entry.dart';
|
||||||
|
|
||||||
|
class ConverterProvider extends ChangeNotifier {
|
||||||
|
List<ConversionEntry> _db = [];
|
||||||
|
List<ConversionEntry> _results = [];
|
||||||
|
ConversionEntry? _selected;
|
||||||
|
double? _grams;
|
||||||
|
bool _rawToCooked = true;
|
||||||
|
|
||||||
|
List<ConversionEntry> get results => _results;
|
||||||
|
ConversionEntry? get selected => _selected;
|
||||||
|
double? get grams => _grams;
|
||||||
|
bool get rawToCooked => _rawToCooked;
|
||||||
|
|
||||||
|
double? get result {
|
||||||
|
if (_selected == null || _grams == null || _grams! <= 0) return null;
|
||||||
|
final v = _rawToCooked
|
||||||
|
? _selected!.rawToCooked(_grams!)
|
||||||
|
: _selected!.cookedToRaw(_grams!);
|
||||||
|
return (v * 10).roundToDouble() / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadDb() async {
|
||||||
|
final raw = await rootBundle.loadString('assets/data/conversions.json');
|
||||||
|
final Map<String, dynamic> json = jsonDecode(raw);
|
||||||
|
_db = [
|
||||||
|
for (final food in json.keys)
|
||||||
|
for (final method in (json[food] as Map).keys)
|
||||||
|
ConversionEntry(
|
||||||
|
food: food,
|
||||||
|
method: method,
|
||||||
|
yieldFactor:
|
||||||
|
(json[food][method]['yield'] as num).toDouble(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void search(String q) {
|
||||||
|
if (q.trim().isEmpty) {
|
||||||
|
_results = [];
|
||||||
|
} else {
|
||||||
|
final lower = q.toLowerCase();
|
||||||
|
_results = _db.where((e) => e.food.contains(lower)).toList();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void select(ConversionEntry entry) {
|
||||||
|
_selected = entry;
|
||||||
|
_results = [];
|
||||||
|
_grams = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setGrams(double? value) {
|
||||||
|
_grams = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void swapDirection() {
|
||||||
|
_rawToCooked = !_rawToCooked;
|
||||||
|
_grams = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_selected = null;
|
||||||
|
_results = [];
|
||||||
|
_grams = null;
|
||||||
|
_rawToCooked = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'app.dart';
|
||||||
|
import 'features/meal_planner/providers/meal_planner_provider.dart';
|
||||||
|
import 'features/converter/providers/converter_provider.dart';
|
||||||
|
import 'features/shopping_list/providers/shopping_list_provider.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||||
|
runApp(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider(create: (_) => MealPlannerProvider()..load()),
|
||||||
|
ChangeNotifierProvider(create: (_) => ConverterProvider()..loadDb()),
|
||||||
|
ChangeNotifierProvider(create: (_) => ShoppingListProvider()..load()),
|
||||||
|
],
|
||||||
|
child: const BitePlanApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class StorageService {
|
||||||
|
static Future<String?> load(String key) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> save(String key, String value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class EmptyState extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
|
const EmptyState({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final muted = theme.colorScheme.onSurface.withValues(alpha: 0.4);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(40),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 48, color: muted),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(color: muted),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: biteplan
|
||||||
|
description: App per meal planning, conversione crudo/cotto e lista della spesa.
|
||||||
|
publish_to: 'none'
|
||||||
|
version: 2.0.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
provider: ^6.1.2
|
||||||
|
shared_preferences: ^2.3.2
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
flutter_launcher_icons: ^0.14.1
|
||||||
|
|
||||||
|
flutter_launcher_icons:
|
||||||
|
android: true
|
||||||
|
ios: false
|
||||||
|
image_path: "assets/icon-only.png"
|
||||||
|
min_sdk_android: 21
|
||||||
|
adaptive_icon_background: "#2d6a4f"
|
||||||
|
adaptive_icon_foreground: "assets/icon-only.png"
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
assets:
|
||||||
|
- assets/data/conversions.json
|
||||||
|
- assets/icon-only.png
|
||||||
Reference in New Issue
Block a user