Files
biteplan/lib/features/converter/presentation/pages/converter_page.dart
T
davide eb7912421c 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>
2026-05-16 17:41:26 +02:00

383 lines
14 KiB
Dart

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();
}