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/scanner_utils.dart'; /// Screen that uses the camera to scan text (OCR) from a Hot Wheels 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 { CameraController? _cameraController; late final TextRecognizer _textRecognizer; bool _isBusy = false; bool _cameraReady = false; String? _lastDetected; bool _scanAccepted = false; String _statusText = 'Ready to scan'; String? _activeCollectionId; bool _autoScanEnabled = false; Timer? _autoScanTimer; @override void initState() { super.initState(); _activeCollectionId = widget.activeCollectionId; _autoScanEnabled = widget.onDetected != null; _textRecognizer = TextRecognizer(); _initCamera(); } void _handleCollectionChanged(String? value) { if (value == null) return; setState(() => _activeCollectionId = value); widget.onCollectionChanged?.call(value); } Future _initCamera() async { final cameras = await availableCameras(); if (cameras.isEmpty) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('No camera available')), ); return; } // Use the first back-facing camera. final backCamera = cameras.firstWhere( (c) => c.lensDirection == CameraLensDirection.back, orElse: () => cameras.first, ); _cameraController = CameraController( backCamera, ResolutionPreset.high, enableAudio: false, ); await _cameraController!.initialize(); if (!mounted) return; setState(() => _cameraReady = true); _startAutoScanLoop(); } void _startAutoScanLoop() { _autoScanTimer?.cancel(); _autoScanTimer = Timer.periodic(const Duration(milliseconds: 1400), (_) { if (!_autoScanEnabled || _isBusy || !_cameraReady || !mounted) return; _captureAndScan(); }); } void _toggleAutoScan() { setState(() { _autoScanEnabled = !_autoScanEnabled; _statusText = _autoScanEnabled ? 'Auto scan enabled' : 'Auto scan paused'; }); } /// Capture a photo, run OCR, and look for a Hot Wheels ID. Future _captureAndScan() async { if (_isBusy || _cameraController == null || !_cameraController!.value.isInitialized) return; setState(() { _isBusy = true; _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) { setState(() => _lastDetected = found); if (widget.onDetected != null) { await _submitDetected(found); } } else { // Show all detected text so user knows what was seen. final allText = recognized.blocks.map((b) => b.text).join('\n'); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( allText.isEmpty ? 'No text detected — try again closer.' : 'No HW ID found. Detected:\n$allText', ), duration: const Duration(seconds: 4), ), ); } } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Scan error: $e'), 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; _statusText = 'Saved. Ready for next scan'; }); Future.delayed(const Duration(milliseconds: 650), () { if (!mounted) return; setState(() => _scanAccepted = false); }); } else { Navigator.of(context).pop(); } } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Process error: $e'), backgroundColor: Colors.red), ); } } @override void dispose() { _autoScanTimer?.cancel(); _cameraController?.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) : Colors.black.withValues(alpha: 0.55), child: Row( children: [ Expanded( child: Text( _statusText, textAlign: TextAlign.center, style: TextStyle( color: _scanAccepted ? AppColors.success : 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: _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 : 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 ? 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'), ), ], ); } }