import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import '../../../domain/entities/tour.dart'; import '../../blocs/scan/scan_bloc.dart'; import 'scan_result_sheet.dart'; class ScanPage extends StatefulWidget { final Tour tour; const ScanPage({ super.key, required this.tour, }); @override State createState() => _ScanPageState(); } class _ScanPageState extends State with SingleTickerProviderStateMixin { late MobileScannerController controller; bool isFlashOn = false; bool isManualEntry = false; final TextEditingController barcodeController = TextEditingController(); @override void initState() { super.initState(); controller = MobileScannerController(); // Initialize scan bloc with tour context.read().add(InitializeScan(widget.tour)); } @override void dispose() { controller.dispose(); barcodeController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( backgroundColor: Colors.black, appBar: AppBar( backgroundColor: Colors.black.withValues(alpha: 128), elevation: 0, title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.tour.locationName ?? 'Station', style: theme.textTheme.titleMedium?.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), ), Text( _getTypeLabel(widget.tour.type), style: theme.textTheme.bodySmall?.copyWith( color: Colors.white70, ), ), ], ), actions: [ IconButton( onPressed: () { setState(() { isFlashOn = !isFlashOn; controller.toggleTorch(); }); }, icon: Icon( isFlashOn ? Icons.flashlight_on : Icons.flashlight_off, color: Colors.white, ), ), IconButton( onPressed: () { controller.switchCamera(); }, icon: const Icon( Icons.flip_camera_android, color: Colors.white, ), ), ], ), body: BlocListener( listener: (context, state) { if (state is ScanObjectDetected) { _showScanResult(context, state); } else if (state is ScanFehlKassetteDetected) { _showFehlKassetteDialog(context, state); } else if (state is ScanContainerObjectDetected) { _showContainerObjectResult(context, state); } else if (state is ScanContainerDetected) { _showContainerDetectedSnackBar(context, state); } else if (state is ScanContainerCloseDetected) { _showContainerCloseDialog(context, state); } else if (state is ScanUnknownObject) { _showUnknownObjectDialog(context, state); } else if (state is ScanPageDetected) { _showPageDetectedSnackBar(context, state); } else if (state is ScanObjectUpdated) { _showSuccessSnackBar(context, state); } else if (state is ScanObjectCreated) { _showObjectCreatedSnackBar(context, state); } else if (state is ScanError) { _showErrorSnackBar(context, state.message); } }, child: Stack( fit: StackFit.expand, children: [ // Camera Preview MobileScanner( controller: controller, onDetect: (capture) { final barcodes = capture.barcodes; if (barcodes.isNotEmpty && barcodes.first.rawValue != null) { HapticFeedback.mediumImpact(); context.read().add( ProcessBarcode(barcode: barcodes.first.rawValue!), ); } }, ), // Scan Overlay CustomPaint( size: Size.infinite, painter: ScanOverlayPainter(), ), // Bottom Controls Positioned( bottom: 0, left: 0, right: 0, child: Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ Colors.black.withValues(alpha: 230), Colors.transparent, ], ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Barcode in den Rahmen halten', style: theme.textTheme.bodyMedium?.copyWith( color: Colors.white70, ), ), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ // Manual Entry Button ElevatedButton.icon( onPressed: _showManualEntryDialog, style: ElevatedButton.styleFrom( backgroundColor: Colors.white, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric( horizontal: 24, vertical: 12, ), ), icon: const Icon(Icons.keyboard), label: const Text('Manuelle Eingabe'), ), ], ), ], ), ), ), // Loading Overlay BlocBuilder( builder: (context, state) { if (state is ScanProcessing) { return Container( color: Colors.black54, child: const Center( child: CircularProgressIndicator(color: Colors.white), ), ); } return const SizedBox.shrink(); }, ), ], ), ), ); } void _showScanResult(BuildContext context, ScanObjectDetected state) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => ScanResultSheet( object: state.object, suggestedState: state.suggestedState, tour: state.tour, onConfirm: () { context.read().add(UpdateObjectState( object: state.object, newState: state.suggestedState, )); Navigator.pop(context); }, onCancel: () { Navigator.pop(context); }, ), ); } void _showUnknownObjectDialog(BuildContext context, ScanUnknownObject state) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Unbekanntes Objekt'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Barcode: ${state.barcode}'), const SizedBox(height: 8), const Text( 'Dieses Objekt ist nicht im System vorhanden. ' 'Möchten Sie es neu anlegen?', ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Abbrechen'), ), ElevatedButton( onPressed: () { Navigator.pop(context); // TODO: Navigate to create object page }, child: const Text('Anlegen'), ), ], ), ); } void _showPageDetectedSnackBar(BuildContext context, ScanPageDetected state) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Seite erkannt: ${state.label}'), backgroundColor: Colors.green, behavior: SnackBarBehavior.floating, action: SnackBarAction( label: 'OK', textColor: Colors.white, onPressed: () {}, ), ), ); } void _showSuccessSnackBar(BuildContext context, ScanObjectUpdated state) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Status aktualisiert: ${state.object.code}'), backgroundColor: Colors.green, behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 2), ), ); } void _showFehlKassetteDialog(BuildContext context, ScanFehlKassetteDetected state) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Fehlkassette'), content: Text( 'Die Kassette ${state.object.code} wird als Fehlkassette markiert. ' 'Der Status wird auf "Fehler - zur Dienststelle" geändert.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Abbrechen'), ), ElevatedButton( onPressed: () { context.read().add(UpdateObjectState( object: state.object, newState: state.suggestedState, )); Navigator.pop(context); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, ), child: const Text('Bestätigen'), ), ], ), ); } void _showContainerObjectResult(BuildContext context, ScanContainerObjectDetected state) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => ScanResultSheet( object: state.object, suggestedState: state.suggestedState, tour: state.tour, containerInfo: 'Container: ${state.containerId} (${state.containerType == 'a' ? 'Geldinstitut' : 'Dienststelle'})', onConfirm: () { context.read().add(UpdateObjectState( object: state.object, newState: state.suggestedState, containerCode: state.containerId, )); Navigator.pop(context); }, onCancel: () { Navigator.pop(context); }, ), ); } void _showContainerDetectedSnackBar(BuildContext context, ScanContainerDetected state) { final containerTypeLabel = state.containerType == 'a' ? 'Geldinstitut' : 'Dienststelle'; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Container erkannt: ${state.containerId} ($containerTypeLabel)'), backgroundColor: Colors.blue, behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 3), action: SnackBarAction( label: 'OK', textColor: Colors.white, onPressed: () { context.read().add(UpdateObjectState( object: state.object, newState: state.suggestedState, )); }, ), ), ); } void _showContainerCloseDialog(BuildContext context, ScanContainerCloseDetected state) { final containerTypeLabel = state.containerType == 'a' ? 'Geldinstitut' : 'Dienststelle'; showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Container schließen'), content: Text( 'Container ${state.object.code} ($containerTypeLabel) wird geschlossen. ' 'Alle enthaltenen Objekte werden auf den entsprechenden Status aktualisiert.', ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Abbrechen'), ), ElevatedButton( onPressed: () { context.read().add(UpdateObjectState( object: state.object, newState: state.suggestedState, )); Navigator.pop(context); }, child: const Text('Schließen'), ), ], ), ); } void _showObjectCreatedSnackBar(BuildContext context, ScanObjectCreated state) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Objekt erstellt: ${state.barcode}'), backgroundColor: Colors.green, behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 2), ), ); } void _showErrorSnackBar(BuildContext context, String message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: Colors.red, behavior: SnackBarBehavior.floating, ), ); } void _showManualEntryDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Barcode manuell eingeben'), content: TextField( controller: barcodeController, autofocus: true, decoration: const InputDecoration( hintText: 'Barcode eingeben', border: OutlineInputBorder(), ), textCapitalization: TextCapitalization.characters, ), actions: [ TextButton( onPressed: () { barcodeController.clear(); Navigator.pop(context); }, child: const Text('Abbrechen'), ), ElevatedButton( onPressed: () { if (barcodeController.text.isNotEmpty) { context.read().add( ProcessBarcode(barcode: barcodeController.text), ); barcodeController.clear(); Navigator.pop(context); } }, child: const Text('Suchen'), ), ], ), ); } String _getTypeLabel(String type) { switch (type) { case 'stock_start': return 'Lager - Beladung'; case 'stock_end': return 'Lager - Rückgabe'; case 'start': return 'Dienststelle'; case 'st': return 'Haltestelle'; case 'hls': return 'Hochbahnstation'; case 'fsa': return 'Fahrscheinautomat'; case 'vs': return 'Versorgungsstelle'; case 'gi': return 'Geldinstitut'; default: return type; } } } class ScanOverlayPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = Colors.black.withValues(alpha: 128) ..style = PaintingStyle.fill; final scanAreaSize = size.width * 0.7; final scanAreaLeft = (size.width - scanAreaSize) / 2; final scanAreaTop = (size.height - scanAreaSize) / 2; // Draw dark overlay final path = Path() ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)); final cutout = Path() ..addRRect(RRect.fromRectAndRadius( Rect.fromLTWH(scanAreaLeft, scanAreaTop, scanAreaSize, scanAreaSize), const Radius.circular(20), )); final overlayPath = Path.combine( PathOperation.difference, path, cutout, ); canvas.drawPath(overlayPath, paint); // Draw corner markers final markerPaint = Paint() ..color = Colors.white ..style = PaintingStyle.stroke ..strokeWidth = 4; final cornerLength = scanAreaSize * 0.15; const cornerRadius = 20.0; // Top-left corner _drawCorner( canvas, Offset(scanAreaLeft, scanAreaTop), cornerLength, markerPaint, true, true, cornerRadius, ); // Top-right corner _drawCorner( canvas, Offset(scanAreaLeft + scanAreaSize, scanAreaTop), cornerLength, markerPaint, false, true, cornerRadius, ); // Bottom-left corner _drawCorner( canvas, Offset(scanAreaLeft, scanAreaTop + scanAreaSize), cornerLength, markerPaint, true, false, cornerRadius, ); // Bottom-right corner _drawCorner( canvas, Offset(scanAreaLeft + scanAreaSize, scanAreaTop + scanAreaSize), cornerLength, markerPaint, false, false, cornerRadius, ); } void _drawCorner( Canvas canvas, Offset position, double length, Paint paint, bool isLeft, bool isTop, double radius, ) { final path = Path(); if (isLeft && isTop) { path.moveTo(position.dx + length, position.dy); path.lineTo(position.dx + radius, position.dy); path.arcToPoint( Offset(position.dx, position.dy + radius), radius: Radius.circular(radius), clockwise: false, ); path.lineTo(position.dx, position.dy + length); } else if (!isLeft && isTop) { path.moveTo(position.dx - length, position.dy); path.lineTo(position.dx - radius, position.dy); path.arcToPoint( Offset(position.dx, position.dy + radius), radius: Radius.circular(radius), ); path.lineTo(position.dx, position.dy + length); } else if (isLeft && !isTop) { path.moveTo(position.dx + length, position.dy); path.lineTo(position.dx + radius, position.dy); path.arcToPoint( Offset(position.dx, position.dy - radius), radius: Radius.circular(radius), ); path.lineTo(position.dx, position.dy - length); } else { path.moveTo(position.dx - length, position.dy); path.lineTo(position.dx - radius, position.dy); path.arcToPoint( Offset(position.dx, position.dy - radius), radius: Radius.circular(radius), clockwise: false, ); path.lineTo(position.dx, position.dy - length); } canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }