Fixes TextEditingController used after being disposed the controller was being manually disposed while the dialog's TextField still held a reference during the pop animation. Now the dialog is its own StatefulWidget so dispose() is called automatically by the framework at the right time.
274 lines
8.3 KiB
Dart
274 lines
8.3 KiB
Dart
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<ScannerScreen> createState() => _ScannerScreenState();
|
||
}
|
||
|
||
class _ScannerScreenState extends State<ScannerScreen> {
|
||
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<void> _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<void> _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<void> _showManualEntry(BuildContext context) async {
|
||
final result = await showDialog<String>(
|
||
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'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|