- Add periodic auto-capture loop for uninterrupted scanner workflow - Start auto-scan after camera init and stop cleanly on dispose - Gate scans by busy/camera-ready state to avoid overlapping captures - Add AUTO ON/OFF toggle in scanner status bar - Keep manual capture path available as fallback
510 lines
17 KiB
Dart
510 lines
17 KiB
Dart
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';
|
||
|
||
/// 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<bool> Function(String hwId)? onDetected;
|
||
final List<Collection> collections;
|
||
final String? activeCollectionId;
|
||
final ValueChanged<String>? onCollectionChanged;
|
||
|
||
const ScannerScreen({
|
||
super.key,
|
||
this.onDetected,
|
||
this.collections = const [],
|
||
this.activeCollectionId,
|
||
this.onCollectionChanged,
|
||
});
|
||
|
||
@override
|
||
State<ScannerScreen> createState() => _ScannerScreenState();
|
||
}
|
||
|
||
class _ScannerScreenState extends State<ScannerScreen> {
|
||
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;
|
||
|
||
// 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();
|
||
_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<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);
|
||
_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<void> _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);
|
||
|
||
// 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);
|
||
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<void> _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<void>.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<String>(
|
||
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<String>(
|
||
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<void> _showManualEntry(BuildContext context) async {
|
||
final result = await showDialog<String>(
|
||
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'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|