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 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 18:38:02 +02:00
parent 36e8034efd
commit cba83c9559
5 changed files with 517 additions and 2 deletions
+2 -2
View File
@@ -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"
+13
View File
@@ -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<Widget> _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,
+4
View File
@@ -1,3 +1,7 @@
const String kAppVersion = '2.0.0';
const String kAppAuthor = 'Davide Grilli';
const String kAppLicense = 'EUPL v1.2';
const List<String> kDayIds = [
'lunedi',
'martedi',
@@ -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<String> 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';
@@ -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<Widget> 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),
],
),
],
),
),
);
}
}