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