521 lines
17 KiB
Dart
521 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';
|
|
import 'utils/scanner_utils.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> {
|
|
static const _autoScanTickInterval = Duration(milliseconds: 700);
|
|
static const _scanCooldownSuccess = Duration(milliseconds: 1500);
|
|
static const _scanCooldownNoMatch = Duration(milliseconds: 2200);
|
|
static const _scanCooldownError = Duration(milliseconds: 2600);
|
|
|
|
CameraController? _cameraController;
|
|
late final TextRecognizer _textRecognizer;
|
|
bool _isBusy = false;
|
|
bool _cameraReady = false;
|
|
String? _lastDetected;
|
|
bool _scanAccepted = false;
|
|
bool _scanNotFound = false;
|
|
String _statusText = 'Ready to scan';
|
|
String? _activeCollectionId;
|
|
bool _autoScanEnabled = false;
|
|
Timer? _autoScanTimer;
|
|
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
|
|
|
@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.medium,
|
|
enableAudio: false,
|
|
);
|
|
|
|
await _cameraController!.initialize();
|
|
if (!mounted) return;
|
|
setState(() => _cameraReady = true);
|
|
_startAutoScanLoop();
|
|
}
|
|
|
|
void _startAutoScanLoop() {
|
|
_autoScanTimer?.cancel();
|
|
_autoScanTimer = Timer.periodic(_autoScanTickInterval, (_) {
|
|
if (!_autoScanEnabled || _isBusy || !_cameraReady || !mounted) return;
|
|
if (DateTime.now().isBefore(_nextScanAllowedAt)) return;
|
|
_captureAndScan();
|
|
});
|
|
}
|
|
|
|
void _toggleAutoScan() {
|
|
setState(() {
|
|
_autoScanEnabled = !_autoScanEnabled;
|
|
_scanNotFound = false;
|
|
_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;
|
|
if (_cameraController!.value.isTakingPicture) return;
|
|
|
|
setState(() {
|
|
_isBusy = true;
|
|
_scanNotFound = false;
|
|
_statusText = 'Scanning…';
|
|
});
|
|
|
|
try {
|
|
final xFile = await _cameraController!.takePicture();
|
|
final inputImage = InputImage.fromFilePath(xFile.path);
|
|
final recognized = await _textRecognizer.processImage(inputImage);
|
|
|
|
final found = extractHwIdFromLines(
|
|
recognized.blocks
|
|
.expand((block) => block.lines)
|
|
.map((line) => line.text),
|
|
);
|
|
|
|
// Clean up the temp image.
|
|
try {
|
|
await File(xFile.path).delete();
|
|
} catch (_) {}
|
|
|
|
if (!mounted) return;
|
|
|
|
if (found != null) {
|
|
_nextScanAllowedAt = DateTime.now().add(_scanCooldownSuccess);
|
|
setState(() => _lastDetected = found);
|
|
if (widget.onDetected != null) {
|
|
await _submitDetected(found);
|
|
}
|
|
} else {
|
|
_nextScanAllowedAt = DateTime.now().add(_scanCooldownNoMatch);
|
|
setState(() {
|
|
_scanAccepted = false;
|
|
_scanNotFound = true;
|
|
_statusText = 'No HW ID found';
|
|
});
|
|
}
|
|
} catch (e) {
|
|
_nextScanAllowedAt = DateTime.now().add(_scanCooldownError);
|
|
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;
|
|
_scanNotFound = false;
|
|
_statusText = 'Saved. Ready for next scan';
|
|
});
|
|
Future<void>.delayed(const Duration(milliseconds: 650), () {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_scanAccepted = false;
|
|
if (_statusText == 'Saved. Ready for next scan') {
|
|
_statusText = 'Ready to scan';
|
|
}
|
|
});
|
|
});
|
|
} 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)
|
|
: _scanNotFound
|
|
? AppColors.error.withValues(alpha: 0.14)
|
|
: Colors.black.withValues(alpha: 0.55),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
_statusText,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: _scanAccepted
|
|
? AppColors.success
|
|
: _scanNotFound
|
|
? AppColors.error
|
|
: 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
|
|
: _scanNotFound
|
|
? AppColors.error
|
|
: 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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|