hwhub/lib/scanner_screen.dart

645 lines
21 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/error_utils.dart';
import 'utils/scanner_utils.dart';
/// Screen that uses the camera to scan text (OCR) from a die-cast 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>
with WidgetsBindingObserver {
static const _autoScanTickInterval = Duration(milliseconds: 700);
static const _scanCooldownSuccess = Duration(milliseconds: 1500);
static const _scanCooldownNoMatch = Duration(milliseconds: 2200);
static const _scanCooldownError = Duration(milliseconds: 2600);
static const _scanCooldownNoMatchMax = Duration(milliseconds: 5000);
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);
bool _isInitializingCamera = false;
String? _cameraError;
int _consecutiveMisses = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_activeCollectionId = widget.activeCollectionId;
_autoScanEnabled = widget.onDetected != null;
_textRecognizer = TextRecognizer();
_initCamera();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
_disposeCamera();
return;
}
if (state == AppLifecycleState.resumed) {
_initCamera(force: true);
}
}
void _handleCollectionChanged(String? value) {
if (value == null) return;
setState(() => _activeCollectionId = value);
widget.onCollectionChanged?.call(value);
}
Future<void> _initCamera({bool force = false}) async {
if (_isInitializingCamera) return;
if (_cameraReady && !force) return;
_isInitializingCamera = true;
try {
await _disposeCamera();
final cameras = await availableCameras();
if (cameras.isEmpty) {
throw Exception('No camera available');
}
final backCamera = cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(
backCamera,
ResolutionPreset.medium,
enableAudio: false,
);
await controller.initialize();
if (!mounted) {
await controller.dispose();
return;
}
_cameraController = controller;
setState(() {
_cameraReady = true;
_cameraError = null;
_statusText = 'Ready to scan';
});
_startAutoScanLoop();
} catch (e) {
if (!mounted) return;
setState(() {
_cameraReady = false;
_cameraError = 'Camera unavailable. Please retry.';
_statusText = 'Camera unavailable';
});
logError('scanner.initCamera', e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
userMessageForError(
e,
fallback: 'Failed to start camera. Please try again.',
),
),
backgroundColor: Colors.red,
),
);
} finally {
_isInitializingCamera = false;
}
}
Future<void> _disposeCamera() async {
_autoScanTimer?.cancel();
_autoScanTimer = null;
final controller = _cameraController;
_cameraController = null;
_cameraReady = false;
if (controller != null) {
await controller.dispose();
}
}
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 die-cast model 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) {
_consecutiveMisses = 0;
_nextScanAllowedAt = DateTime.now().add(_scanCooldownSuccess);
setState(() => _lastDetected = found);
if (widget.onDetected != null) {
await _submitDetected(found);
}
} else {
_consecutiveMisses += 1;
final missBackoffMs = (_scanCooldownNoMatch.inMilliseconds +
(_consecutiveMisses * 300))
.clamp(
_scanCooldownNoMatch.inMilliseconds,
_scanCooldownNoMatchMax.inMilliseconds,
);
_nextScanAllowedAt = DateTime.now().add(
Duration(milliseconds: missBackoffMs),
);
setState(() {
_scanAccepted = false;
_scanNotFound = true;
_statusText = 'No HW ID found';
});
}
} catch (e) {
_consecutiveMisses += 1;
_nextScanAllowedAt = DateTime.now().add(_scanCooldownError);
if (!mounted) return;
logError('scanner.capture', e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
userMessageForError(
e,
fallback: 'Scan failed. Please try again.',
),
),
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;
logError('scanner.submitDetected', e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
userMessageForError(
e,
fallback: 'Could not process this scan. Please try again.',
),
),
backgroundColor: Colors.red,
),
);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_autoScanTimer?.cancel();
final controller = _cameraController;
_cameraController = null;
if (controller != null) {
controller.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: _cameraError != null
? Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.camera_alt_outlined,
size: 46, color: AppColors.error),
const SizedBox(height: 12),
Text(
_cameraError!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _initCamera(force: true),
icon: const Icon(Icons.refresh),
label: const Text('Retry Camera'),
),
],
),
),
)
: _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 || !_cameraReady ? 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'),
),
],
);
}
}