import 'dart:io'; import 'package:flutter/material.dart'; import 'package:camera/camera.dart'; import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.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 { const ScannerScreen({super.key}); @override State createState() => _ScannerScreenState(); } class _ScannerScreenState extends State { CameraController? _cameraController; late final TextRecognizer _textRecognizer; bool _isBusy = false; bool _cameraReady = false; String? _lastDetected; // Matches typical Hot Wheels model IDs: 2–5 uppercase letters followed by // 2–4 digits, e.g. JKF21, HCV73, GRX33, FYD83. final _hwIdPattern = RegExp(r'\b([A-Z]{2,5}\d{2,4})\b'); @override void initState() { super.initState(); _textRecognizer = TextRecognizer(); _initCamera(); } 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); } /// 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); try { final xFile = await _cameraController!.takePicture(); final inputImage = InputImage.fromFilePath(xFile.path); final recognized = await _textRecognizer.processImage(inputImage); // Search all recognized text blocks for something matching the HW ID pattern. String? found; for (final block in recognized.blocks) { for (final line in block.lines) { final match = _hwIdPattern.firstMatch(line.text.toUpperCase()); if (match != null) { found = match.group(1); break; } } if (found != null) break; } // Clean up the temp image. try { await File(xFile.path).delete(); } catch (_) {} if (!mounted) return; if (found != null) { setState(() => _lastDetected = 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), ); } finally { if (mounted) setState(() => _isBusy = false); } } @override void dispose() { _cameraController?.dispose(); _textRecognizer.close(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Scan Hot Wheels ID')), body: Column( children: [ // ── Camera preview ── Expanded( child: _cameraReady ? ClipRect( child: SizedBox.expand( child: FittedBox( fit: BoxFit.cover, child: SizedBox( width: _cameraController!.value.previewSize!.height, height: _cameraController!.value.previewSize!.width, child: CameraPreview(_cameraController!), ), ), ), ) : const Center(child: CircularProgressIndicator()), ), // ── Detected ID confirmation area ── if (_lastDetected != null) Container( width: double.infinity, color: Colors.green.shade50, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ const Icon(Icons.check_circle, color: Colors.green), const SizedBox(width: 12), Expanded( child: Text( 'Detected: $_lastDetected', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), ElevatedButton( onPressed: () => Navigator.of(context).pop(_lastDetected), child: const Text('Use'), ), ], ), ), // ── Bottom controls ── SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ // Manual entry button Expanded( child: OutlinedButton.icon( icon: const Icon(Icons.keyboard), label: const Text('Enter manually'), onPressed: () => _showManualEntry(context), ), ), const SizedBox(width: 12), // Capture / scan button Expanded( child: ElevatedButton.icon( icon: _isBusy ? const SizedBox( height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.camera_alt), label: Text(_isBusy ? 'Scanning...' : 'Scan'), onPressed: _isBusy ? null : _captureAndScan, ), ), ], ), ), ), ], ), ); } /// 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) { Navigator.of(context).pop(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', border: OutlineInputBorder(), ), onSubmitted: (_) => _submit(), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), ElevatedButton( onPressed: _submit, child: const Text('OK'), ), ], ); } }