import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:camera/camera.dart'; import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; import 'services/collection_service.dart'; import 'theme/app_colors.dart'; import 'utils/error_utils.dart'; import 'utils/scanner_utils.dart'; /// Screen that uses the camera to scan text (OCR) from a die-cast package /// and extract the hw_id (e.g. "JKF21"). /// /// The detected ID is returned via Navigator.pop(context, hwId). class ScannerScreen extends StatefulWidget { final Future Function(String hwId)? onDetected; final List collections; final String? activeCollectionId; final ValueChanged? onCollectionChanged; const ScannerScreen({ super.key, this.onDetected, this.collections = const [], this.activeCollectionId, this.onCollectionChanged, }); @override State createState() => _ScannerScreenState(); } class _ScannerScreenState extends State with WidgetsBindingObserver { static const _autoScanTickInterval = Duration(milliseconds: 700); static const _scanCooldownSuccess = Duration(milliseconds: 1500); static const _scanCooldownNoMatch = Duration(milliseconds: 2200); static const _scanCooldownError = Duration(milliseconds: 2600); static const _scanCooldownNoMatchMax = Duration(milliseconds: 5000); CameraController? _cameraController; late final TextRecognizer _textRecognizer; bool _isBusy = false; bool _cameraReady = false; String? _lastDetected; bool _scanAccepted = false; bool _scanNotFound = false; String _statusText = 'Ready to scan'; String? _activeCollectionId; bool _autoScanEnabled = false; Timer? _autoScanTimer; DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0); bool _isInitializingCamera = false; String? _cameraError; int _consecutiveMisses = 0; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _activeCollectionId = widget.activeCollectionId; if (widget.onDetected != null) { _statusText = 'Auto scan paused'; } _textRecognizer = TextRecognizer(); _initCamera(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.inactive || state == AppLifecycleState.paused || state == AppLifecycleState.detached) { _disposeCamera(); return; } if (state == AppLifecycleState.resumed) { _initCamera(force: true); } } void _handleCollectionChanged(String? value) { if (value == null) return; setState(() => _activeCollectionId = value); widget.onCollectionChanged?.call(value); } Future _initCamera({bool force = false}) async { if (_isInitializingCamera) return; if (_cameraReady && !force) return; _isInitializingCamera = true; try { await _disposeCamera(); final cameras = await availableCameras(); if (cameras.isEmpty) { throw const ValidationException('No camera available'); } final backCamera = cameras.firstWhere( (c) => c.lensDirection == CameraLensDirection.back, orElse: () => cameras.first, ); final controller = CameraController( backCamera, ResolutionPreset.medium, enableAudio: false, ); await controller.initialize(); if (!mounted) { await controller.dispose(); return; } _cameraController = controller; setState(() { _cameraReady = true; _cameraError = null; _statusText = 'Ready to scan'; }); _startAutoScanLoop(); } catch (e) { if (!mounted) return; setState(() { _cameraReady = false; _cameraError = 'Camera unavailable. Please retry.'; _statusText = 'Camera unavailable'; }); logError('scanner.initCamera', e); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( userMessageForError( e, fallback: 'Failed to start camera. Please try again.', ), ), backgroundColor: Colors.red, ), ); } finally { _isInitializingCamera = false; } } Future _disposeCamera() async { _autoScanTimer?.cancel(); _autoScanTimer = null; final controller = _cameraController; _cameraController = null; _cameraReady = false; if (controller != null) { await controller.dispose(); } } void _startAutoScanLoop() { _autoScanTimer?.cancel(); _autoScanTimer = Timer.periodic(_autoScanTickInterval, (_) { if (!_autoScanEnabled || _isBusy || !_cameraReady || !mounted) return; if (DateTime.now().isBefore(_nextScanAllowedAt)) return; _captureAndScan(); }); } void _toggleAutoScan() { setState(() { _autoScanEnabled = !_autoScanEnabled; _scanNotFound = false; _statusText = _autoScanEnabled ? 'Auto scan enabled' : 'Auto scan paused'; }); } /// Capture a photo, run OCR, and look for a die-cast model ID. Future _captureAndScan() async { if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return; if (_cameraController!.value.isTakingPicture) return; setState(() { _isBusy = true; _scanNotFound = false; _statusText = 'Scanning…'; }); try { final xFile = await _cameraController!.takePicture(); final inputImage = InputImage.fromFilePath(xFile.path); final recognized = await _textRecognizer.processImage(inputImage); final found = extractHwIdFromLines( recognized.blocks .expand((block) => block.lines) .map((line) => line.text), ); // Clean up the temp image. try { await File(xFile.path).delete(); } catch (_) {} if (!mounted) return; if (found != null) { _consecutiveMisses = 0; _nextScanAllowedAt = DateTime.now().add(_scanCooldownSuccess); setState(() => _lastDetected = found); if (widget.onDetected != null) { await _submitDetected(found); } } else { _consecutiveMisses += 1; final missBackoffMs = (_scanCooldownNoMatch.inMilliseconds + (_consecutiveMisses * 300)) .clamp( _scanCooldownNoMatch.inMilliseconds, _scanCooldownNoMatchMax.inMilliseconds, ); _nextScanAllowedAt = DateTime.now().add( Duration(milliseconds: missBackoffMs), ); setState(() { _scanAccepted = false; _scanNotFound = true; _statusText = 'No HW ID found'; }); } } catch (e) { _consecutiveMisses += 1; _nextScanAllowedAt = DateTime.now().add(_scanCooldownError); if (!mounted) return; logError('scanner.capture', e); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( userMessageForError( e, fallback: 'Scan failed. Please try again.', ), ), backgroundColor: Colors.red, ), ); setState(() => _statusText = 'Scan failed, try again'); } finally { if (mounted) { setState(() { _isBusy = false; if (_statusText == 'Scanning…') { _statusText = 'Ready to scan'; } }); } } } Future _submitDetected(String hwId) async { if (widget.onDetected == null) { if (mounted) Navigator.of(context).pop(hwId); return; } try { final keepScanning = await widget.onDetected!(hwId); if (!mounted) return; if (keepScanning) { setState(() { _lastDetected = null; _scanAccepted = true; _scanNotFound = false; _statusText = 'Saved. Ready for next scan'; }); Future.delayed(const Duration(milliseconds: 650), () { if (!mounted) return; setState(() { _scanAccepted = false; if (_statusText == 'Saved. Ready for next scan') { _statusText = 'Ready to scan'; } }); }); } else { Navigator.of(context).pop(); } } catch (e) { if (!mounted) return; logError('scanner.submitDetected', e); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( userMessageForError( e, fallback: 'Could not process this scan. Please try again.', ), ), backgroundColor: Colors.red, ), ); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _autoScanTimer?.cancel(); final controller = _cameraController; _cameraController = null; if (controller != null) { controller.dispose(); } _textRecognizer.close(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( extendBodyBehindAppBar: true, appBar: AppBar( title: const Text( 'Scan HW ID', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600), ), backgroundColor: Colors.black38, iconTheme: const IconThemeData(color: Colors.white), ), body: Column( children: [ if (widget.collections.isNotEmpty) Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), color: Colors.black.withValues(alpha: 0.55), child: DropdownButtonHideUnderline( child: DropdownButton( isExpanded: true, value: _activeCollectionId, dropdownColor: AppColors.navy, iconEnabledColor: Colors.white, style: const TextStyle(color: Colors.white), hint: const Text( 'Select collection', style: TextStyle(color: Colors.white70), ), items: widget.collections .map( (c) => DropdownMenuItem( value: c.id, child: Text( c.name, overflow: TextOverflow.ellipsis, ), ), ) .toList(), onChanged: _isBusy ? null : _handleCollectionChanged, ), ), ), Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), color: _scanAccepted ? AppColors.success.withValues(alpha: 0.12) : _scanNotFound ? AppColors.error.withValues(alpha: 0.14) : Colors.black.withValues(alpha: 0.55), child: Row( children: [ Expanded( child: Text( _statusText, textAlign: TextAlign.center, style: TextStyle( color: _scanAccepted ? AppColors.success : _scanNotFound ? AppColors.error : Colors.white, fontSize: 12, fontWeight: FontWeight.w600, ), ), ), if (widget.onDetected != null) TextButton( onPressed: _isBusy ? null : _toggleAutoScan, style: TextButton.styleFrom( foregroundColor: _autoScanEnabled ? AppColors.success : Colors.white, minimumSize: const Size(0, 26), padding: const EdgeInsets.symmetric(horizontal: 10), ), child: Text(_autoScanEnabled ? 'AUTO ON' : 'AUTO OFF'), ), ], ), ), // ── Camera preview ── Expanded( child: _cameraError != null ? Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.camera_alt_outlined, size: 46, color: AppColors.error), const SizedBox(height: 12), Text( _cameraError!, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.textSecondary), ), const SizedBox(height: 12), OutlinedButton.icon( onPressed: () => _initCamera(force: true), icon: const Icon(Icons.refresh), label: const Text('Retry Camera'), ), ], ), ), ) : _cameraReady ? Stack( fit: StackFit.expand, children: [ ClipRect( child: SizedBox.expand( child: FittedBox( fit: BoxFit.cover, child: SizedBox( width: _cameraController!.value.previewSize!.height, height: _cameraController!.value.previewSize!.width, child: CameraPreview(_cameraController!), ), ), ), ), // Scan overlay / crosshair Center( child: Container( width: 240, height: 100, decoration: BoxDecoration( border: Border.all( color: (_scanAccepted ? AppColors.success : _scanNotFound ? AppColors.error : AppColors.orange) .withValues(alpha: 0.9), width: 2.5, ), borderRadius: BorderRadius.circular(16), ), child: Center( child: Text( 'Align ID here', style: TextStyle( color: Colors.white.withValues(alpha: 0.7), fontSize: 13, fontWeight: FontWeight.w500, ), ), ), ), ), ], ) : const Center(child: CircularProgressIndicator()), ), // ── Detected ID confirmation area ── if (_lastDetected != null) Container( width: double.infinity, decoration: BoxDecoration( color: AppColors.success.withValues(alpha: 0.1), border: Border( top: BorderSide( color: AppColors.success.withValues(alpha: 0.4), width: 1.5, ), ), ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: AppColors.success.withValues(alpha: 0.15), shape: BoxShape.circle, ), child: const Icon(Icons.check, color: AppColors.success, size: 20), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Detected', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), Text( _lastDetected!, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w700, letterSpacing: 1.5, color: AppColors.success, ), ), ], ), ), ElevatedButton( onPressed: _isBusy || _lastDetected == null ? null : () => _submitDetected(_lastDetected!), child: const Text('Use This'), ), ], ), ), // ── Bottom controls ── SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Expanded( child: OutlinedButton.icon( icon: const Icon(Icons.keyboard), label: const Text('Manual'), onPressed: () => _showManualEntry(context), ), ), const SizedBox(width: 12), Expanded( flex: 2, child: SizedBox( height: 50, child: DecoratedBox( decoration: BoxDecoration( gradient: _isBusy ? null : AppColors.brandGradient, borderRadius: BorderRadius.circular(50), ), child: ElevatedButton.icon( icon: _isBusy ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator( strokeWidth: 2.5, color: Colors.white), ) : const Icon(Icons.camera_alt, color: Colors.white), label: Text( _isBusy ? 'Scanning…' : 'Capture & Scan', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600), ), onPressed: _isBusy || !_cameraReady ? null : _captureAndScan, style: ElevatedButton.styleFrom( backgroundColor: Colors.transparent, shadowColor: Colors.transparent, ), ), ), ), ), ], ), ), ), ], ), ); } /// Fallback: let the user type the HW ID manually. Future _showManualEntry(BuildContext context) async { final result = await showDialog( context: context, builder: (_) => const _ManualEntryDialog(), ); if (result != null && context.mounted) { await _submitDetected(result); } } } // ── Manual Entry Dialog ─────────────────────────────────────────────── class _ManualEntryDialog extends StatefulWidget { const _ManualEntryDialog(); @override State<_ManualEntryDialog> createState() => _ManualEntryDialogState(); } class _ManualEntryDialogState extends State<_ManualEntryDialog> { final _controller = TextEditingController(); @override void dispose() { _controller.dispose(); super.dispose(); } void _submit() { final value = _controller.text.trim().toUpperCase(); if (value.isNotEmpty) Navigator.of(context).pop(value); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Enter HW ID'), content: TextField( controller: _controller, autofocus: true, textCapitalization: TextCapitalization.characters, decoration: const InputDecoration( hintText: 'e.g. JKF21', ), onSubmitted: (_) => _submit(), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), ElevatedButton( onPressed: _submit, child: const Text('OK'), ), ], ); } }