- AuthGate now tracks wasLoggedIn vs isLoggedIn and only calls setState when the login status actually flips, preventing token-refresh events from tearing down open dialogs and causing _dependents.isEmpty crashes - Fix TextEditingController disposal order in scanner manual entry dialog
253 lines
7.8 KiB
Dart
253 lines
7.8 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 controller = TextEditingController();
|
||
|
||
final result = await showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => 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(),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
child: const Text('Cancel'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () {
|
||
final value = controller.text.trim().toUpperCase();
|
||
if (value.isNotEmpty) Navigator.of(ctx).pop(value);
|
||
},
|
||
child: const Text('OK'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (result != null && context.mounted) {
|
||
controller.dispose();
|
||
Navigator.of(context).pop(result);
|
||
} else {
|
||
controller.dispose();
|
||
}
|
||
}
|
||
}
|