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();
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No camera available')),
|
||||
);
|
||||
return;
|
||||
throw Exception('No camera available');
|
||||
}
|
||||
|
||||
// Use the first back-facing camera.
|
||||
final backCamera = cameras.firstWhere(
|
||||
(c) => c.lensDirection == CameraLensDirection.back,
|
||||
orElse: () => cameras.first,
|
||||
);
|
||||
|
||||
_cameraController = CameraController(
|
||||
final controller = CameraController(
|
||||
backCamera,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
);
|
||||
|
||||
await _cameraController!.initialize();
|
||||
if (!mounted) return;
|
||||
setState(() => _cameraReady = true);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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: [
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue