diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/assets/data/conversions.json b/assets/data/conversions.json new file mode 100644 index 0000000..a61da9b --- /dev/null +++ b/assets/data/conversions.json @@ -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 } } +} diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..7a4ee60 --- /dev/null +++ b/lib/app.dart @@ -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 _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', + ), + ], + ), + ); + } +} diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..2bf290a --- /dev/null +++ b/lib/core/constants/app_constants.dart @@ -0,0 +1,30 @@ +const List kDayIds = [ + 'lunedi', + 'martedi', + 'mercoledi', + 'giovedi', + 'venerdi', + 'sabato', + 'domenica', +]; + +const Map kDayLabels = { + 'lunedi': 'Lunedì', + 'martedi': 'Martedì', + 'mercoledi': 'Mercoledì', + 'giovedi': 'Giovedì', + 'venerdi': 'Venerdì', + 'sabato': 'Sabato', + 'domenica': 'Domenica', +}; + +const List kMealSlots = ['colazione', 'pranzo', 'cena']; + +const Map kMealSlotLabels = { + 'colazione': 'Colazione', + 'pranzo': 'Pranzo', + 'cena': 'Cena', +}; + +const String kStorageKeyMeals = 'meals'; +const String kStorageKeyShopping = 'shopping_list'; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..d0392df --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -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), + ), + ); + } +} diff --git a/lib/features/converter/models/conversion_entry.dart b/lib/features/converter/models/conversion_entry.dart new file mode 100644 index 0000000..08646cd --- /dev/null +++ b/lib/features/converter/models/conversion_entry.dart @@ -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; +} diff --git a/lib/features/converter/presentation/pages/converter_page.dart b/lib/features/converter/presentation/pages/converter_page.dart new file mode 100644 index 0000000..ea8fd82 --- /dev/null +++ b/lib/features/converter/presentation/pages/converter_page.dart @@ -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 createState() => _ConverterPageState(); +} + +class _ConverterPageState extends State { + 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().select(entry); + } + + void _onReset() { + _gramsController.clear(); + context.read().reset(); + } + + void _onSwap() { + _gramsController.clear(); + context.read().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(); + + 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().search(''); + }, + ) + : null, + ), + onChanged: (v) { + setState(() {}); + context.read().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() + .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(); +} diff --git a/lib/features/converter/providers/converter_provider.dart b/lib/features/converter/providers/converter_provider.dart new file mode 100644 index 0000000..cdb0018 --- /dev/null +++ b/lib/features/converter/providers/converter_provider.dart @@ -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 _db = []; + List _results = []; + ConversionEntry? _selected; + double? _grams; + bool _rawToCooked = true; + + List 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 loadDb() async { + final raw = await rootBundle.loadString('assets/data/conversions.json'); + final Map 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(); + } +} diff --git a/lib/features/meal_planner/models/meal_plan.dart b/lib/features/meal_planner/models/meal_plan.dart new file mode 100644 index 0000000..1ddedec --- /dev/null +++ b/lib/features/meal_planner/models/meal_plan.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +class DayPlan { + final List colazione; + final List pranzo; + final List cena; + + DayPlan({ + List? colazione, + List? pranzo, + List? cena, + }) : colazione = colazione ?? [], + pranzo = pranzo ?? [], + cena = cena ?? []; + + DayPlan copyWith({ + List? colazione, + List? pranzo, + List? cena, + }) => + DayPlan( + colazione: colazione ?? List.of(this.colazione), + pranzo: pranzo ?? List.of(this.pranzo), + cena: cena ?? List.of(this.cena), + ); + + List slot(String name) => switch (name) { + 'colazione' => colazione, + 'pranzo' => pranzo, + 'cena' => cena, + _ => const [], + }; + + Map toJson() => { + 'colazione': colazione, + 'pranzo': pranzo, + 'cena': cena, + }; + + factory DayPlan.fromJson(Map json) => DayPlan( + colazione: List.from(json['colazione'] ?? []), + pranzo: List.from(json['pranzo'] ?? []), + cena: List.from(json['cena'] ?? []), + ); +} + +class MealPlan { + final Map days; + + const MealPlan(this.days); + + factory MealPlan.empty(List 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 get allItems => days.values + .expand((d) => [...d.colazione, ...d.pranzo, ...d.cena]) + .toList(); + + Map toJson() => + {for (final e in days.entries) e.key: e.value.toJson()}; + + factory MealPlan.fromJson(Map json) => MealPlan({ + for (final e in json.entries) + e.key: DayPlan.fromJson(e.value as Map), + }); + + String toJsonString() => jsonEncode(toJson()); + + factory MealPlan.fromJsonString(String s) => + MealPlan.fromJson(jsonDecode(s) as Map); +} diff --git a/lib/features/meal_planner/presentation/pages/meal_planner_page.dart b/lib/features/meal_planner/presentation/pages/meal_planner_page.dart new file mode 100644 index 0000000..fe3f79b --- /dev/null +++ b/lib/features/meal_planner/presentation/pages/meal_planner_page.dart @@ -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 _confirmClear(BuildContext context) async { + final ok = await showDialog( + 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().clearAll(); + } + } + + void _generateShopping(BuildContext context) { + final plan = context.read().plan; + context.read().addAll(plan.allItems); + onGoShopping(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final provider = context.watch(); + 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().addItem(dayId, slot, text), + onRemove: (slot, index) => + context.read().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'), + ), + ], + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/meal_planner/presentation/widgets/meal_card.dart b/lib/features/meal_planner/presentation/widgets/meal_card.dart new file mode 100644 index 0000000..d103811 --- /dev/null +++ b/lib/features/meal_planner/presentation/widgets/meal_card.dart @@ -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 createState() => _MealCardState(); +} + +class _MealCardState extends State { + late final Map _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 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), + ), + ], + ), + ); + } +} diff --git a/lib/features/meal_planner/providers/meal_planner_provider.dart b/lib/features/meal_planner/providers/meal_planner_provider.dart new file mode 100644 index 0000000..a70da16 --- /dev/null +++ b/lib/features/meal_planner/providers/meal_planner_provider.dart @@ -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 load() async { + final json = await StorageService.load(kStorageKeyMeals); + if (json != null) { + try { + _plan = MealPlan.fromJsonString(json); + } catch (_) { + _plan = MealPlan.empty(kDayIds); + } + } + notifyListeners(); + } + + Future _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(); + } +} diff --git a/lib/features/shopping_list/models/shopping_item.dart b/lib/features/shopping_list/models/shopping_item.dart new file mode 100644 index 0000000..8f8854d --- /dev/null +++ b/lib/features/shopping_list/models/shopping_item.dart @@ -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 toJson() => { + 'id': id, + 'name': name, + 'checked': checked, + }; + + factory ShoppingItem.fromJson(Map json) => ShoppingItem( + id: json['id'] as String, + name: json['name'] as String, + checked: json['checked'] as bool? ?? false, + ); +} diff --git a/lib/features/shopping_list/presentation/pages/shopping_list_page.dart b/lib/features/shopping_list/presentation/pages/shopping_list_page.dart new file mode 100644 index 0000000..eed501b --- /dev/null +++ b/lib/features/shopping_list/presentation/pages/shopping_list_page.dart @@ -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 createState() => _ShoppingListPageState(); +} + +class _ShoppingListPageState extends State { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _add() { + final text = _controller.text.trim(); + if (text.isEmpty) return; + context.read().add(text); + _controller.clear(); + } + + Future _confirmClear() async { + final ok = await showDialog( + 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().clearAll(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final provider = context.watch(); + + 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() + .toggle(provider.pendingItems[i].id), + onRemove: () => context + .read() + .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() + .toggle(provider.checkedItems[i].id), + onRemove: () => context + .read() + .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'), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/shopping_list/presentation/widgets/shopping_item_tile.dart b/lib/features/shopping_list/presentation/widgets/shopping_item_tile.dart new file mode 100644 index 0000000..d022c8c --- /dev/null +++ b/lib/features/shopping_list/presentation/widgets/shopping_item_tile.dart @@ -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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/shopping_list/providers/shopping_list_provider.dart b/lib/features/shopping_list/providers/shopping_list_provider.dart new file mode 100644 index 0000000..e4b020a --- /dev/null +++ b/lib/features/shopping_list/providers/shopping_list_provider.dart @@ -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 _items = []; + + List get items => _items; + List get pendingItems => + _items.where((i) => !i.checked).toList(); + List get checkedItems => + _items.where((i) => i.checked).toList(); + + Future 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)) + .toList(); + } catch (_) { + _items = []; + } + } + notifyListeners(); + } + + Future _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 names) { + final existing = _items.map((i) => i.name.toLowerCase()).toSet(); + final seen = {}; + final toAdd = []; + 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(); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..90380ec --- /dev/null +++ b/lib/main.dart @@ -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(), + ), + ); +} diff --git a/lib/shared/services/storage_service.dart b/lib/shared/services/storage_service.dart new file mode 100644 index 0000000..30a58d5 --- /dev/null +++ b/lib/shared/services/storage_service.dart @@ -0,0 +1,13 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class StorageService { + static Future load(String key) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(key); + } + + static Future save(String key, String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(key, value); + } +} diff --git a/lib/shared/widgets/empty_state.dart b/lib/shared/widgets/empty_state.dart new file mode 100644 index 0000000..e455f82 --- /dev/null +++ b/lib/shared/widgets/empty_state.dart @@ -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, + ), + ], + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..c76ea62 --- /dev/null +++ b/pubspec.yaml @@ -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