hwhub/lib/scanner_screen.dart
Lukas Müllner 13114bd63f feat(scanner): add auto-scan cadence with pause/resume controls
- 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
2026-03-04 11:15:43 +01:00

510 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: 25 uppercase letters followed by
// 24 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'),
),
],
);
}
}