fix: harden scanner camera preview recovery
This commit is contained in:
parent
bebd21097d
commit
d566621033
1 changed files with 114 additions and 28 deletions
|
|
@ -30,7 +30,8 @@ class ScannerScreen extends StatefulWidget {
|
||||||
State<ScannerScreen> createState() => _ScannerScreenState();
|
State<ScannerScreen> createState() => _ScannerScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ScannerScreenState extends State<ScannerScreen> {
|
class _ScannerScreenState extends State<ScannerScreen>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
static const _autoScanTickInterval = Duration(milliseconds: 700);
|
static const _autoScanTickInterval = Duration(milliseconds: 700);
|
||||||
static const _scanCooldownSuccess = Duration(milliseconds: 1500);
|
static const _scanCooldownSuccess = Duration(milliseconds: 1500);
|
||||||
static const _scanCooldownNoMatch = Duration(milliseconds: 2200);
|
static const _scanCooldownNoMatch = Duration(milliseconds: 2200);
|
||||||
|
|
@ -48,48 +49,103 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
bool _autoScanEnabled = false;
|
bool _autoScanEnabled = false;
|
||||||
Timer? _autoScanTimer;
|
Timer? _autoScanTimer;
|
||||||
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
bool _isInitializingCamera = false;
|
||||||
|
String? _cameraError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_activeCollectionId = widget.activeCollectionId;
|
_activeCollectionId = widget.activeCollectionId;
|
||||||
_autoScanEnabled = widget.onDetected != null;
|
_autoScanEnabled = widget.onDetected != null;
|
||||||
_textRecognizer = TextRecognizer();
|
_textRecognizer = TextRecognizer();
|
||||||
_initCamera();
|
_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) {
|
void _handleCollectionChanged(String? value) {
|
||||||
if (value == null) return;
|
if (value == null) return;
|
||||||
setState(() => _activeCollectionId = value);
|
setState(() => _activeCollectionId = value);
|
||||||
widget.onCollectionChanged?.call(value);
|
widget.onCollectionChanged?.call(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initCamera() async {
|
Future<void> _initCamera({bool force = false}) async {
|
||||||
final cameras = await availableCameras();
|
if (_isInitializingCamera) return;
|
||||||
if (cameras.isEmpty) {
|
if (_cameraReady && !force) return;
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
_isInitializingCamera = true;
|
||||||
const SnackBar(content: Text('No camera available')),
|
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,
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
|
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';
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Camera init failed: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_isInitializingCamera = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use the first back-facing camera.
|
Future<void> _disposeCamera() async {
|
||||||
final backCamera = cameras.firstWhere(
|
_autoScanTimer?.cancel();
|
||||||
(c) => c.lensDirection == CameraLensDirection.back,
|
_autoScanTimer = null;
|
||||||
orElse: () => cameras.first,
|
final controller = _cameraController;
|
||||||
);
|
_cameraController = null;
|
||||||
|
_cameraReady = false;
|
||||||
_cameraController = CameraController(
|
if (controller != null) {
|
||||||
backCamera,
|
await controller.dispose();
|
||||||
ResolutionPreset.medium,
|
}
|
||||||
enableAudio: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
await _cameraController!.initialize();
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _cameraReady = true);
|
|
||||||
_startAutoScanLoop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startAutoScanLoop() {
|
void _startAutoScanLoop() {
|
||||||
|
|
@ -210,8 +266,13 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_autoScanTimer?.cancel();
|
_autoScanTimer?.cancel();
|
||||||
_cameraController?.dispose();
|
final controller = _cameraController;
|
||||||
|
_cameraController = null;
|
||||||
|
if (controller != null) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
_textRecognizer.close();
|
_textRecognizer.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -302,7 +363,32 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
),
|
),
|
||||||
// ── Camera preview ──
|
// ── Camera preview ──
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _cameraReady
|
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(
|
? Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -349,7 +435,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: const Center(child: CircularProgressIndicator()),
|
: const Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Detected ID confirmation area ──
|
// ── Detected ID confirmation area ──
|
||||||
|
|
@ -440,7 +526,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
||||||
_isBusy ? 'Scanning…' : 'Capture & Scan',
|
_isBusy ? 'Scanning…' : 'Capture & Scan',
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
onPressed: _isBusy ? null : _captureAndScan,
|
onPressed: _isBusy || !_cameraReady ? null : _captureAndScan,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
shadowColor: Colors.transparent,
|
shadowColor: Colors.transparent,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue