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();
}
class _ScannerScreenState extends State<ScannerScreen> {
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);
@ -48,48 +49,103 @@ class _ScannerScreenState extends State<ScannerScreen> {
bool _autoScanEnabled = false;
Timer? _autoScanTimer;
DateTime _nextScanAllowedAt = DateTime.fromMillisecondsSinceEpoch(0);
bool _isInitializingCamera = false;
String? _cameraError;
@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() async {
final cameras = await availableCameras();
if (cameras.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No camera available')),
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,
);
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.
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();
Future<void> _disposeCamera() async {
_autoScanTimer?.cancel();
_autoScanTimer = null;
final controller = _cameraController;
_cameraController = null;
_cameraReady = false;
if (controller != null) {
await controller.dispose();
}
}
void _startAutoScanLoop() {
@ -210,8 +266,13 @@ class _ScannerScreenState extends State<ScannerScreen> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_autoScanTimer?.cancel();
_cameraController?.dispose();
final controller = _cameraController;
_cameraController = null;
if (controller != null) {
controller.dispose();
}
_textRecognizer.close();
super.dispose();
}
@ -302,7 +363,32 @@ class _ScannerScreenState extends State<ScannerScreen> {
),
// Camera preview
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(
fit: StackFit.expand,
children: [
@ -349,7 +435,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
),
],
)
: const Center(child: CircularProgressIndicator()),
: const Center(child: CircularProgressIndicator()),
),
// Detected ID confirmation area
@ -440,7 +526,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
_isBusy ? 'Scanning…' : 'Capture & Scan',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
),
onPressed: _isBusy ? null : _captureAndScan,
onPressed: _isBusy || !_cameraReady ? null : _captureAndScan,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,