fix: harden scanner camera preview recovery

This commit is contained in:
Lukas Müllner 2026-03-05 10:43:36 +01:00
parent bebd21097d
commit d566621033

View file

@ -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,