From cba83c955938f24547fd122f9b1c57b596f42e12 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sat, 16 May 2026 18:38:02 +0200 Subject: [PATCH] feat: aggiungi guida utente e icona launcher Guida utente: - Pulsante info nell'AppBar principale - InfoBottomSheet: icona app, versione, autore, licenza, link alla guida - GuidePage: tre tab (Pasti / Converti / Spesa) con card e step numerati, portata dalla DocsPanel Vue originale AppBar globale in app.dart con titolo dinamico per tab corrente. Icona launcher: - flutter_launcher_icons eseguito in build.sh prima di flutter build apk (sia debug che release) per applicare assets/icon-only.png all'APK. Co-Authored-By: Claude Sonnet 4.6 --- docker/build/build.sh | 4 +- lib/app.dart | 13 + lib/core/constants/app_constants.dart | 4 + .../guide/presentation/pages/guide_page.dart | 334 ++++++++++++++++++ .../widgets/info_bottom_sheet.dart | 164 +++++++++ 5 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 lib/features/guide/presentation/pages/guide_page.dart create mode 100644 lib/features/guide/presentation/widgets/info_bottom_sheet.dart diff --git a/docker/build/build.sh b/docker/build/build.sh index 81ea4d4..dd61318 100755 --- a/docker/build/build.sh +++ b/docker/build/build.sh @@ -36,7 +36,7 @@ if [[ "$RELEASE" == "true" ]]; then -v "${PROJECT_ROOT}:/workspace" \ -v "${HOME}/.gradle:/root/.gradle" \ biteplan-build \ - bash -c "cd /workspace && flutter pub get && flutter build apk --release" + bash -c "cd /workspace && flutter pub get && flutter pub run flutter_launcher_icons && flutter build apk --release" echo "→ Zipalign..." docker run --rm \ @@ -67,7 +67,7 @@ else -v "${PROJECT_ROOT}:/workspace" \ -v "${HOME}/.gradle:/root/.gradle" \ biteplan-build \ - bash -c "cd /workspace && flutter pub get && flutter build apk --debug" + bash -c "cd /workspace && flutter pub get && flutter pub run flutter_launcher_icons && flutter build apk --debug" cp "${PROJECT_ROOT}/build/app/outputs/flutter-apk/app-debug.apk" \ "${PROJECT_ROOT}/dist/biteplan-debug.apk" diff --git a/lib/app.dart b/lib/app.dart index 7a4ee60..48844a4 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,6 +3,7 @@ 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'; +import 'features/guide/presentation/widgets/info_bottom_sheet.dart'; class BitePlanApp extends StatelessWidget { const BitePlanApp({super.key}); @@ -29,6 +30,8 @@ class _MainScaffoldState extends State<_MainScaffold> { int _currentIndex = 0; late final List _pages; + static const _titles = ['Piano Pasti', 'Convertitore', 'Lista della spesa']; + @override void initState() { super.initState(); @@ -44,6 +47,16 @@ class _MainScaffoldState extends State<_MainScaffold> { @override Widget build(BuildContext context) { return Scaffold( + appBar: AppBar( + title: Text(_titles[_currentIndex]), + actions: [ + IconButton( + icon: const Icon(Icons.info_outline), + tooltip: 'Informazioni', + onPressed: () => showInfoBottomSheet(context), + ), + ], + ), body: IndexedStack( index: _currentIndex, children: _pages, diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 2bf290a..ccc55d2 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -1,3 +1,7 @@ +const String kAppVersion = '2.0.0'; +const String kAppAuthor = 'Davide Grilli'; +const String kAppLicense = 'EUPL v1.2'; + const List kDayIds = [ 'lunedi', 'martedi', diff --git a/lib/features/guide/presentation/pages/guide_page.dart b/lib/features/guide/presentation/pages/guide_page.dart new file mode 100644 index 0000000..ac87359 --- /dev/null +++ b/lib/features/guide/presentation/pages/guide_page.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import '../../../../core/constants/app_constants.dart'; + +class GuidePage extends StatelessWidget { + const GuidePage({super.key}); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('Guida'), + bottom: TabBar( + tabs: const [ + Tab(text: 'Pasti'), + Tab(text: 'Converti'), + Tab(text: 'Spesa'), + ], + labelColor: Theme.of(context).colorScheme.primary, + indicatorColor: Theme.of(context).colorScheme.primary, + ), + ), + body: const TabBarView( + children: [ + _PastiTab(), + _ConvertiTab(), + _SpesaTab(), + ], + ), + ), + ); + } +} + +// ── Pasti ──────────────────────────────────────────────────────────────────── + +class _PastiTab extends StatelessWidget { + const _PastiTab(); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: const [ + _DocCard( + title: 'Aggiungere un alimento', + child: _Steps(steps: [ + 'Tocca il giorno per espandere la card', + 'Scegli il pasto: Colazione, Pranzo o Cena', + 'Scrivi il nome nel campo di testo', + 'Premi + o Invio per aggiungerlo', + ]), + ), + _TipCard( + text: + 'Premi "Genera lista della spesa" in fondo alla pagina per importare automaticamente tutti gli alimenti della settimana, senza duplicati.', + ), + _DocCard( + title: 'Rimuovere un alimento', + child: _Body( + text: + 'Tocca il pulsante × a destra dell\'elemento per eliminarlo.'), + ), + _DocCard( + title: 'Salvataggio automatico', + child: _Body( + text: + 'I dati vengono salvati automaticamente sul dispositivo. Non serve premere nessun tasto.'), + ), + ], + ); + } +} + +// ── Converti ───────────────────────────────────────────────────────────────── + +class _ConvertiTab extends StatelessWidget { + const _ConvertiTab(); + + static const _categories = [ + ('Cereali e pasta', 'Riso (4 varietà), pasta, farro, orzo, quinoa, cous cous'), + ('Legumi secchi', 'Ceci, fagioli, lenticchie'), + ('Verdure', 'Carote, zucchine, patate, spinaci, broccoli, asparagi e altri'), + ('Carni', 'Pollo petto, tacchino fesa, hamburger, vitello'), + ('Pesce', 'Tonno, merluzzo, spigola, sogliola'), + ('Uova', 'Uovo al tegamino, frittata'), + ]; + + static const _methods = ['Bollitura', 'Padella', 'Forno', 'Friggitrice ad aria']; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const _DocCard( + title: 'Come usarlo', + child: _Steps(steps: [ + 'Cerca l\'alimento nel campo (es. pollo, riso)', + 'Seleziona il metodo di cottura dall\'elenco', + 'Inserisci il peso in grammi', + 'Il risultato appare in tempo reale', + 'Premi il pulsante "Cambia" per invertire crudo / cotto', + ]), + ), + _DocCard( + title: 'Alimenti disponibili', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + border: Border.all(color: scheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: _categories.indexed.map((entry) { + final (i, cat) = entry; + return Container( + decoration: BoxDecoration( + border: i < _categories.length - 1 + ? Border( + bottom: BorderSide(color: scheme.outlineVariant)) + : null, + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 9), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 110, + child: Text(cat.$1, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13)), + ), + const SizedBox(width: 8), + Expanded( + child: Text(cat.$2, + style: TextStyle( + fontSize: 12, + color: scheme.onSurfaceVariant)), + ), + ], + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 6, + runSpacing: 6, + children: _methods + .map((m) => Chip( + label: Text(m, + style: const TextStyle(fontSize: 12)), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + )) + .toList(), + ), + ], + ), + ), + ], + ); + } +} + +// ── Spesa ───────────────────────────────────────────────────────────────────── + +class _SpesaTab extends StatelessWidget { + const _SpesaTab(); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: const [ + _DocCard( + title: 'Aggiungere un elemento', + child: _Body( + text: 'Scrivi il nome nel campo in alto e premi + o Invio.'), + ), + _TipCard( + text: + 'Vai alla tab Pasti e premi "Genera lista della spesa" per importare automaticamente gli alimenti pianificati, senza duplicati.', + ), + _DocCard( + title: 'Spuntare un elemento', + child: _Body( + text: + 'Tocca la casella a sinistra. Gli elementi completati vengono spostati in una sezione separata con testo barrato.'), + ), + _DocCard( + title: 'Rimuovere / svuotare', + child: _Body( + text: + 'Tocca × per rimuovere un singolo elemento, oppure "Svuota lista" in fondo per eliminare tutto (richiede conferma).'), + ), + ], + ); + } +} + +// ── Componenti condivisi ────────────────────────────────────────────────────── + +class _DocCard extends StatelessWidget { + final String title; + final Widget child; + const _DocCard({required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 10), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.7, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + const SizedBox(height: 10), + child, + ], + ), + ), + ); + } +} + +class _TipCard extends StatelessWidget { + final String text; + const _TipCard({required this.text}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.primaryContainer.withValues(alpha: 0.4), + border: Border(left: BorderSide(color: scheme.primary, width: 3)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, size: 14, color: scheme.primary), + const SizedBox(width: 5), + Text('SUGGERIMENTO', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + letterSpacing: 0.7, + color: scheme.primary)), + ], + ), + const SizedBox(height: 6), + Text(text, style: const TextStyle(fontSize: 14, height: 1.5)), + ], + ), + ); + } +} + +class _Steps extends StatelessWidget { + final List steps; + const _Steps({required this.steps}); + + @override + Widget build(BuildContext context) { + return Column( + children: steps.indexed.map((entry) { + final (i, step) = entry; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: Text('${i + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 10), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(step, + style: + const TextStyle(fontSize: 14, height: 1.5)), + )), + ], + ), + ); + }).toList(), + ); + } +} + +class _Body extends StatelessWidget { + final String text; + const _Body({required this.text}); + + @override + Widget build(BuildContext context) { + return Text(text, style: const TextStyle(fontSize: 14, height: 1.5)); + } +} + +// ignore footer version — accessed via kAppVersion constant +String get _footerText => 'BitePlan · v$kAppVersion · $kAppAuthor'; diff --git a/lib/features/guide/presentation/widgets/info_bottom_sheet.dart b/lib/features/guide/presentation/widgets/info_bottom_sheet.dart new file mode 100644 index 0000000..81462cb --- /dev/null +++ b/lib/features/guide/presentation/widgets/info_bottom_sheet.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import '../../../../core/constants/app_constants.dart'; +import '../pages/guide_page.dart'; + +void showInfoBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => const _InfoSheet(), + ); +} + +class _InfoSheet extends StatelessWidget { + const _InfoSheet(); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // handle + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: scheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + // icona + nome + versione + Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset( + 'assets/icon-only.png', + width: 52, + height: 52, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 14), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('BitePlan', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold)), + Text('Versione $kAppVersion', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: scheme.onSurfaceVariant)), + ], + ), + ], + ), + const SizedBox(height: 20), + // righe info + _InfoCard(children: [ + _InfoRow(label: 'Autore', value: kAppAuthor), + _InfoRow(label: 'Licenza', value: kAppLicense), + _InfoRowButton( + label: 'Guida', + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const GuidePage()), + ); + }, + ), + ]), + ], + ), + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + final List children; + const _InfoCard({required this.children}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + border: Border.all(color: scheme.outlineVariant), + borderRadius: BorderRadius.circular(12), + ), + child: Column(children: children), + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + const _InfoRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: Theme.of(context).textTheme.bodyMedium), + Text(value, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: scheme.onSurfaceVariant)), + ], + ), + ); + } +} + +class _InfoRowButton extends StatelessWidget { + final String label; + final VoidCallback onTap; + const _InfoRowButton({required this.label, required this.onTap}); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return InkWell( + onTap: onTap, + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: Theme.of(context).textTheme.bodyMedium), + Row( + children: [ + Text('Apri', + style: TextStyle( + color: scheme.primary, fontWeight: FontWeight.w600)), + const SizedBox(width: 4), + Icon(Icons.chevron_right, size: 18, color: scheme.primary), + ], + ), + ], + ), + ), + ); + } +}