diff --git a/lib/features/meal_planner/presentation/pages/meal_planner_page.dart b/lib/features/meal_planner/presentation/pages/meal_planner_page.dart index fe3f79b..c8277ee 100644 --- a/lib/features/meal_planner/presentation/pages/meal_planner_page.dart +++ b/lib/features/meal_planner/presentation/pages/meal_planner_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/meal_planner_provider.dart'; import '../widgets/meal_card.dart'; +import '../widgets/qr_share_sheet.dart'; +import '../pages/qr_scan_page.dart'; import '../../../shopping_list/providers/shopping_list_provider.dart'; import '../../../../core/constants/app_constants.dart'; @@ -136,6 +138,32 @@ class MealPlannerPage extends StatelessWidget { child: const Text('Svuota piano'), ), ], + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: provider.plan.hasAnyMeal + ? () => showQrShareSheet(context, provider.plan) + : null, + icon: const Icon(Icons.qr_code, size: 18), + label: const Text('Condividi'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const QrScanPage()), + ), + icon: const Icon(Icons.qr_code_scanner, size: 18), + label: const Text('Ricevi'), + ), + ), + ], + ), ], ), ), diff --git a/lib/features/meal_planner/presentation/pages/qr_scan_page.dart b/lib/features/meal_planner/presentation/pages/qr_scan_page.dart new file mode 100644 index 0000000..81629bd --- /dev/null +++ b/lib/features/meal_planner/presentation/pages/qr_scan_page.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:provider/provider.dart'; +import '../../models/meal_plan.dart'; +import '../../providers/meal_planner_provider.dart'; +import '../../../../core/constants/app_constants.dart'; + +class QrScanPage extends StatefulWidget { + const QrScanPage({super.key}); + + @override + State createState() => _QrScanPageState(); +} + +class _QrScanPageState extends State { + bool _handled = false; + + void _onDetect(BarcodeCapture capture, BuildContext context) { + if (_handled) return; + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw == null) return; + + setState(() => _handled = true); + + try { + final parsed = jsonDecode(raw) as Map; + if (parsed['v'] != 1 || parsed['meals'] is! Map) { + _showError(context, 'QR non valido: dati non riconosciuti.'); + return; + } + final mealsMap = parsed['meals'] as Map; + for (final day in kDayIds) { + final dayData = mealsMap[day]; + if (dayData is! Map) { + _showError(context, 'QR non valido: struttura dati errata.'); + return; + } + for (final slot in kMealSlots) { + if (dayData[slot] is! List) { + _showError(context, 'QR non valido: struttura dati errata.'); + return; + } + } + } + + final plan = MealPlan(Map.fromEntries( + kDayIds.map((day) { + final d = mealsMap[day] as Map; + return MapEntry( + day, + DayPlan( + colazione: List.from(d['colazione'] as List), + pranzo: List.from(d['pranzo'] as List), + cena: List.from(d['cena'] as List), + ), + ); + }), + )); + + context.read().importPlan(plan); + + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Piano ricevuto!')), + ); + } + } catch (_) { + _showError(context, 'QR non riconosciuto.'); + } + } + + void _showError(BuildContext context, String msg) { + if (!context.mounted) return; + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Scansiona QR')), + body: Stack( + children: [ + MobileScanner( + onDetect: (capture) => _onDetect(capture, context), + ), + // cornice di mira + Center( + child: Container( + width: 220, + height: 220, + decoration: BoxDecoration( + border: Border.all(color: scheme.primary, width: 3), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + Positioned( + bottom: 40, + left: 0, right: 0, + child: Text( + 'Inquadra il codice QR dell\'altro dispositivo', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/meal_planner/presentation/widgets/qr_share_sheet.dart b/lib/features/meal_planner/presentation/widgets/qr_share_sheet.dart new file mode 100644 index 0000000..07241d6 --- /dev/null +++ b/lib/features/meal_planner/presentation/widgets/qr_share_sheet.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import '../../models/meal_plan.dart'; + +const int _kQrMaxBytes = 2953; + +void showQrShareSheet(BuildContext context, MealPlan plan) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => _QrShareSheet(plan: plan), + ); +} + +class _QrShareSheet extends StatelessWidget { + final MealPlan plan; + const _QrShareSheet({required this.plan}); + + String? _buildPayload() { + final payload = jsonEncode({'v': 1, 'meals': plan.days.map( + (k, v) => MapEntry(k, { + 'colazione': v.colazione, + 'pranzo': v.pranzo, + 'cena': v.cena, + }), + )}); + if (payload.length > _kQrMaxBytes) return null; + return payload; + } + + @override + Widget build(BuildContext context) { + final payload = _buildPayload(); + final scheme = Theme.of(context).colorScheme; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // handle + Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: scheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Condividi piano', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.bold)), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Fai scansionare questo codice dall\'altro dispositivo', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + if (payload == null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + 'Dati troppo grandi per un QR code.\nRiduci il numero di alimenti inseriti.', + textAlign: TextAlign.center, + style: TextStyle(color: scheme.error), + ), + ) + else + QrImageView( + data: payload, + version: QrVersions.auto, + size: 260, + errorCorrectionLevel: QrErrorCorrectLevel.L, + backgroundColor: Colors.white, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/meal_planner/providers/meal_planner_provider.dart b/lib/features/meal_planner/providers/meal_planner_provider.dart index a70da16..40a03ce 100644 --- a/lib/features/meal_planner/providers/meal_planner_provider.dart +++ b/lib/features/meal_planner/providers/meal_planner_provider.dart @@ -59,4 +59,10 @@ class MealPlannerProvider extends ChangeNotifier { notifyListeners(); _save(); } + + void importPlan(MealPlan plan) { + _plan = plan; + notifyListeners(); + _save(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index c76ea62..a5a20ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: sdk: flutter provider: ^6.1.2 shared_preferences: ^2.3.2 + qr_flutter: ^4.1.0 + mobile_scanner: ^5.0.0 dev_dependencies: flutter_test: