From ca1fa29ea2b8587c9f20e589ebbba5e04764d003 Mon Sep 17 00:00:00 2001 From: Edouard Marquez Date: Wed, 11 May 2022 16:49:58 +0200 Subject: [PATCH] feat: Scanner improvements (#1781) * All barcode detections are now computed in an Isolate * Add a setStateSafe method * Only accept 1D barcodes * Between each decoding a window is passed * Let's try to reduce a little bit the quality of the camera * Improve a little bit the documentation * Fix a typo * Fix some typos * Fixes issues highlighted by MonsieurTanuki in the PR * Remove irrelevant `mount` check * Fix wrong import --- .../data_models/continuous_scan_model.dart | 12 +- .../lib/helpers/collections_helper.dart | 50 +++ .../lib/pages/scan/mkit_scan_helper.dart | 322 ++++++++++++++++++ .../lib/pages/scan/ml_kit_scan_page.dart | 218 ++++++------ .../lib/widgets/lifecycle_aware_widget.dart | 85 +++++ packages/smooth_app/pubspec.lock | 16 +- packages/smooth_app/pubspec.yaml | 4 +- 7 files changed, 590 insertions(+), 117 deletions(-) create mode 100644 packages/smooth_app/lib/helpers/collections_helper.dart create mode 100644 packages/smooth_app/lib/pages/scan/mkit_scan_helper.dart create mode 100644 packages/smooth_app/lib/widgets/lifecycle_aware_widget.dart diff --git a/packages/smooth_app/lib/data_models/continuous_scan_model.dart b/packages/smooth_app/lib/data_models/continuous_scan_model.dart index 63ef1af346b..60a093e5313 100644 --- a/packages/smooth_app/lib/data_models/continuous_scan_model.dart +++ b/packages/smooth_app/lib/data_models/continuous_scan_model.dart @@ -97,23 +97,25 @@ class ContinuousScanModel with ChangeNotifier { Product getProduct(final String barcode) => _productList.getProduct(barcode); - Future onScan(String? code) async { + /// Adds a barcode + /// Will return [true] if this barcode is successfully added + Future onScan(String? code) async { if (code == null) { - return; + return false; } if (_barcodeTrustCheck != code) { _barcodeTrustCheck = code; - return; + return false; } if (_latestScannedBarcode == code || _barcodes.contains(code)) { lastConsultedBarcode = code; - return; + return false; } AnalyticsHelper.trackScannedProduct(barcode: code); _latestScannedBarcode = code; - _addBarcode(code); + return _addBarcode(code); } Future onCreateProduct(String? barcode) async { diff --git a/packages/smooth_app/lib/helpers/collections_helper.dart b/packages/smooth_app/lib/helpers/collections_helper.dart new file mode 100644 index 00000000000..c0a48a53d25 --- /dev/null +++ b/packages/smooth_app/lib/helpers/collections_helper.dart @@ -0,0 +1,50 @@ +import 'dart:collection'; + +import 'package:collection/collection.dart'; + +/// List of [num] with a max length of [_maxCapacity], where we can easily +/// compute the average value of all elements. +class AverageList with ListMixin { + static const int _maxCapacity = 10; + final List _elements = []; + + int average(int defaultValueIfEmpty) { + if (_elements.isEmpty) { + return defaultValueIfEmpty; + } else { + return _elements.average.floor(); + } + } + + @override + int get length => _elements.length; + + @override + T operator [](int index) => throw UnsupportedError( + 'Please only use the "add" method', + ); + + @override + void operator []=(int index, T value) { + if (index > _maxCapacity) { + throw UnsupportedError('The index is above the capacity!'); + } else { + _elements[index] = value; + } + } + + @override + void add(T element) { + // The first element is always the latest added + _elements.insert(0, element); + + if (_elements.length >= _maxCapacity) { + _elements.removeLast(); + } + } + + @override + set length(int newLength) { + throw UnimplementedError('This list has a fixed size of $_maxCapacity'); + } +} diff --git a/packages/smooth_app/lib/pages/scan/mkit_scan_helper.dart b/packages/smooth_app/lib/pages/scan/mkit_scan_helper.dart new file mode 100644 index 00000000000..ab8583b8503 --- /dev/null +++ b/packages/smooth_app/lib/pages/scan/mkit_scan_helper.dart @@ -0,0 +1,322 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:camera/camera.dart'; +import 'package:flutter_isolate/flutter_isolate.dart'; +import 'package:google_ml_barcode_scanner/google_ml_barcode_scanner.dart'; +import 'package:smooth_app/pages/preferences/user_preferences_dev_mode.dart'; +import 'package:smooth_app/pages/scan/abstract_camera_image_getter.dart'; +import 'package:smooth_app/pages/scan/camera_image_cropper.dart'; +import 'package:smooth_app/pages/scan/camera_image_full_getter.dart'; + +/// ML Kit bar code decoder (within an Isolate) +class MLKitScanDecoder { + MLKitScanDecoder({ + required CameraDescription camera, + required this.scanMode, + }) : _mainIsolate = _MLKitScanDecoderMainIsolate( + camera: camera, + scanMode: scanMode, + ); + + final DevModeScanMode scanMode; + final _MLKitScanDecoderMainIsolate _mainIsolate; + + /// Extract barcodes from an image + /// + /// A null result is sent when the [scanMode] is unsupported or if a current + /// decoding is already in progress + /// Otherwise a list of decoded barcoded is returned + /// Note: This list may be empty if no barcode is detected + Future?> processImage(CameraImage image) async { + switch (scanMode) { + case DevModeScanMode.CAMERA_ONLY: + case DevModeScanMode.PREPROCESS_FULL_IMAGE: + case DevModeScanMode.PREPROCESS_HALF_IMAGE: + return null; + case DevModeScanMode.SCAN_FULL_IMAGE: + case DevModeScanMode.SCAN_HALF_IMAGE: + // OK -> continue + } + + return _mainIsolate.decode(image); + } + + Future dispose() async { + _mainIsolate.dispose(); + } +} + +/// Main class allowing to communicate with [_MLKitScanDecoderIsolate] +/// The communication is bi-directional: +/// -> From the main Isolate to the "decoder" Isolate +/// -> Send the configuration (camera description + scan mode) +/// -> Send a camera image to decode +/// <- From the "decoder" Isolate +/// -> When the Isolate is started (a [SendPort] is provided to communicate) +/// -> When the Isolate is ready (camera description & scan mode are provided) +/// -> When an image is decoded +class _MLKitScanDecoderMainIsolate { + _MLKitScanDecoderMainIsolate({ + required this.camera, + required this.scanMode, + }) : _port = ReceivePort() { + _port.listen((dynamic message) { + if (message is SendPort) { + _sendPort = message; + + _sendPort!.send( + _MLKitScanDecoderIsolate._createConfig( + camera, + scanMode, + ), + ); + } else if (message is bool) { + _isIsolateInitialized = true; + + if (_queuedImage != null && _completer != null) { + _sendPort!.send(_queuedImage!.export()); + _queuedImage = null; + } + } else if (message is List) { + _completer?.complete(message as List); + _completer = null; + } + }); + } + + final CameraDescription camera; + final DevModeScanMode scanMode; + + /// Port used by the Isolate to send us events: + /// - when a [SendPort] is available + /// - when the Isolate is ready to receive images + /// - when the Isolate has finished a detection + final ReceivePort _port; + + /// Port provided by the Isolate to send instructions + /// - send the configuration (camera & scan mode) + /// - send a camera image + SendPort? _sendPort; + + FlutterIsolate? _isolate; + + /// Flag used when the Isolate is both started and ready to receive images + bool _isIsolateInitialized = false; + + /// When an image is provided, this [Completer] allows to notify the response + Completer?>? _completer; + + /// When the Isolate is started, we have to wait until [_isIsolateInitialized] + /// is [true]. This variable temporary contains the image waiting to be + /// decoded. + CameraImage? _queuedImage; + + /// Decodes barcodes from a [CameraImage] + /// A null result will be sent until the Isolate isn't ready + Future?> decode(CameraImage image) async { + // If a decoding process is running -> ignore new requests + if (_completer != null) { + return null; + } + + _completer = Completer?>(); + + if (_isolate == null) { + _isolate = await FlutterIsolate.spawn( + _MLKitScanDecoderIsolate.startIsolate, + _port.sendPort, + ); + _queuedImage = image; + return _completer!.future; + } else if (_isIsolateInitialized) { + _sendPort?.send(image.export()); + return _completer!.future; + } + + return null; + } + + void dispose() { + _isIsolateInitialized = false; + + _completer?.completeError(Exception('Isolate stopped')); + _completer = null; + + _sendPort = null; + + _isolate?.kill(priority: Isolate.immediate); + _isolate = null; + } +} + +// ignore: avoid_classes_with_only_static_members +class _MLKitScanDecoderIsolate { + // Only 1D barcodes. More info on: + // [https://www.scandit.com/blog/types-barcodes-choosing-right-barcode/] + static final BarcodeScanner _barcodeScanner = + GoogleMlKit.vision.barcodeScanner( + [ + BarcodeFormat.ean8, + BarcodeFormat.ean13, + BarcodeFormat.upca, + BarcodeFormat.upce, + BarcodeFormat.code39, + BarcodeFormat.code93, + BarcodeFormat.code128, + BarcodeFormat.itf, + BarcodeFormat.codabar, + ], + ); + + static const String _cameraKey = 'camera'; + static const String _scanModeKey = 'scanMode'; + + /// Port to communicate with the main Isolate + static late ReceivePort? _port; + static CameraDescription? _camera; + static DevModeScanMode? _scanMode; + + static void startIsolate(SendPort port) { + _port = ReceivePort(); + + _port!.listen( + (dynamic message) async { + if (message is Map) { + if (message.containsKey(_cameraKey)) { + _initIsolate(message); + port.send(true); + } else { + await onNewBarcode(message, port); + } + } + }, + ); + + port.send(_port!.sendPort); + } + + /// Content required for this Isolate to be "ready" + static Map _createConfig( + CameraDescription camera, + DevModeScanMode scanMode, + ) { + return { + _cameraKey: camera.export(), + _scanModeKey: scanMode.index, + }; + } + + /// Parse content containing the configuration of this Isolate + // ignore: avoid_dynamic_calls + static void _initIsolate(Map message) { + _camera = _CameraDescriptionUtils.import( + message[_cameraKey] as Map, + ); + _scanMode = DevModeScanMode.values[message[_scanModeKey] as int]; + } + + static bool get isReady => _camera != null && _scanMode != null; + + static Future onNewBarcode( + Map message, + SendPort port, + ) async { + if (!isReady) { + return; + } + + final CameraImage image = CameraImage.fromPlatformData(message); + final InputImage cropImage = _cropImage(image); + + final List barcodes = + await _barcodeScanner.processImage(cropImage); + + port.send( + barcodes + .map((Barcode barcode) => barcode.value.rawValue) + .where((String? barcode) => barcode?.isNotEmpty == true) + .cast() + .toList(growable: false), + ); + } + + static InputImage _cropImage(CameraImage image) { + final AbstractCameraImageGetter getter; + + switch (_scanMode!) { + case DevModeScanMode.CAMERA_ONLY: + case DevModeScanMode.PREPROCESS_FULL_IMAGE: + case DevModeScanMode.PREPROCESS_HALF_IMAGE: + throw Exception('Unsupported mode $_scanMode!'); + case DevModeScanMode.SCAN_FULL_IMAGE: + getter = CameraImageFullGetter(image, _camera!); + break; + case DevModeScanMode.SCAN_HALF_IMAGE: + getter = CameraImageCropper( + image, + _camera!, + left01: 0, + top01: 0, + width01: 1, + height01: .5, + ); + break; + } + + return getter.getInputImage(); + } +} + +/// [Isolate]s don't support custom classes. +/// This extension exports raw data from a [CameraImage], to be able to call +/// [CameraImage.fromPlatformData] +extension _CameraImageExtension on CameraImage { + Map export() => { + 'format': format.raw, + 'width': width, + 'height': height, + 'lensAperture': lensAperture, + 'sensorExposureTime': sensorExposureTime, + 'sensorSensitivity': sensorSensitivity, + 'planes': planes + .map((Plane p) => { + 'bytes': p.bytes, + 'bytesPerPixel': p.bytesPerPixel, + 'bytesPerRow': p.bytesPerRow, + 'height': p.height, + 'width': p.width, + }) + .toList( + growable: false, + ) + }; +} + +/// [Isolate]s don't support custom classes. +/// This extension exports raw data from a [CameraDescription], to be able to +/// call the [CameraDescription] constructor via [_CameraDescriptionUtils.import]. +extension _CameraDescriptionExtension on CameraDescription { + Map export() => { + _CameraDescriptionUtils._nameKey: name, + _CameraDescriptionUtils._lensDirectionKey: lensDirection.index, + _CameraDescriptionUtils._sensorOrientationKey: sensorOrientation, + }; +} + +/// Recreate a [CameraDescription] from [_CameraDescriptionExtension.export] +class _CameraDescriptionUtils { + const _CameraDescriptionUtils._(); + + static const String _nameKey = 'name'; + static const String _lensDirectionKey = 'lensDirection'; + static const String _sensorOrientationKey = 'sensorOrientation'; + + static CameraDescription import(Map map) { + return CameraDescription( + name: map[_nameKey] as String, + lensDirection: CameraLensDirection.values[map[_lensDirectionKey] as int], + sensorOrientation: map[_sensorOrientationKey] as int, + ); + } +} diff --git a/packages/smooth_app/lib/pages/scan/ml_kit_scan_page.dart b/packages/smooth_app/lib/pages/scan/ml_kit_scan_page.dart index 131c84fc00e..a9774efa09f 100644 --- a/packages/smooth_app/lib/pages/scan/ml_kit_scan_page.dart +++ b/packages/smooth_app/lib/pages/scan/ml_kit_scan_page.dart @@ -1,17 +1,19 @@ +import 'dart:async'; + import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_ml_barcode_scanner/google_ml_barcode_scanner.dart'; import 'package:provider/provider.dart'; +import 'package:rxdart/rxdart.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; import 'package:smooth_app/data_models/user_preferences.dart'; import 'package:smooth_app/helpers/camera_helper.dart'; +import 'package:smooth_app/helpers/collections_helper.dart'; import 'package:smooth_app/pages/preferences/user_preferences_dev_mode.dart'; -import 'package:smooth_app/pages/scan/abstract_camera_image_getter.dart'; -import 'package:smooth_app/pages/scan/camera_image_cropper.dart'; -import 'package:smooth_app/pages/scan/camera_image_full_getter.dart'; import 'package:smooth_app/pages/scan/lifecycle_manager.dart'; +import 'package:smooth_app/pages/scan/mkit_scan_helper.dart'; +import 'package:smooth_app/widgets/lifecycle_aware_widget.dart'; import 'package:smooth_app/widgets/screen_visibility.dart'; class MLKitScannerPage extends StatelessWidget { @@ -27,14 +29,17 @@ class MLKitScannerPage extends StatelessWidget { } } -class _MLKitScannerPageContent extends StatefulWidget { - const _MLKitScannerPageContent({Key? key}) : super(key: key); +class _MLKitScannerPageContent extends LifecycleAwareStatefulWidget { + const _MLKitScannerPageContent({ + Key? key, + }) : super(key: key); @override MLKitScannerPageState createState() => MLKitScannerPageState(); } -class MLKitScannerPageState extends State<_MLKitScannerPageContent> { +class MLKitScannerPageState + extends LifecycleAwareState<_MLKitScannerPageContent> { /// If the camera is being closed (when [stoppingCamera] == true) and this /// Widget is visible again, we add a post frame callback to detect if the /// Widget is still visible @@ -45,32 +50,50 @@ class MLKitScannerPageState extends State<_MLKitScannerPageContent> { /// On a 60Hz display, one frame =~ 16 ms => 100 ms =~ 6 frames. static const int postFrameCallbackStandardDelay = 100; // in milliseconds - static const int _SKIPPED_FRAMES = 10; - BarcodeScanner? barcodeScanner; + /// To improve battery life & lower the CPU consumption, we decode barcodes + /// every [_processingTimeWindows] time windows. + + /// Until the first barcode is decoded, this is default timeout + static const int _defaultProcessingTime = 50; // in milliseconds + /// Minimal processing windows between two decodings + static const int _processingTimeWindows = 5; + + /// A time window is the average time decodings took + final AverageList _averageProcessingTime = AverageList(); + + /// Subject notifying when a new image is available + PublishSubject _subject = PublishSubject(); + + /// Stream calling the barcode detection + StreamSubscription?>? _streamSubscription; + MLKitScanDecoder? _barcodeDecoder; + late ContinuousScanModel _model; late UserPreferences _userPreferences; CameraController? _controller; CameraDescription? _camera; - bool isBusy = false; double _previewScale = 1.0; /// Flag used to prevent the camera from being initialized. /// When set to [false], [_startLiveStream] can be called. bool stoppingCamera = false; - //We don't scan every image for performance reasons - int frameCounter = 0; - @override void initState() { super.initState(); _camera = CameraHelper.findBestCamera(); + _subject = PublishSubject(); } @override void didChangeDependencies() { super.didChangeDependencies(); + if (mounted) { + _model = context.watch(); + _userPreferences = context.watch(); + } + // Relaunch the feed after a hot reload if (_controller == null) { _startLiveFeed(); @@ -79,15 +102,12 @@ class MLKitScannerPageState extends State<_MLKitScannerPageContent> { @override Widget build(BuildContext context) { - _model = context.watch(); - _userPreferences = context.watch(); - // [_startLiveFeed] is called both with [onResume] and [onPause] to cover // all entry points return LifeCycleManager( onStart: _startLiveFeed, onResume: _startLiveFeed, - onPause: _stopImageStream, + onPause: () => _stopImageStream(fromPauseEvent: true), child: _buildScannerWidget(), ); } @@ -96,7 +116,7 @@ class MLKitScannerPageState extends State<_MLKitScannerPageContent> { // Showing the black scanner background + the icon when the scanner is // loading or stopped if (isCameraNotInitialized) { - return const SizedBox(); + return const SizedBox.shrink(); } final Size size = MediaQuery.of(context).size; @@ -139,25 +159,65 @@ class MLKitScannerPageState extends State<_MLKitScannerPageContent> { return; } - barcodeScanner = GoogleMlKit.vision.barcodeScanner(); stoppingCamera = false; _controller = CameraController( _camera!, - ResolutionPreset.high, + ResolutionPreset.medium, enableAudio: false, imageFormatGroup: ImageFormatGroup.yuv420, ); // If the controller is initialized update the UI. + _barcodeDecoder ??= MLKitScanDecoder( + camera: _camera!, + scanMode: DevModeScanModeExtension.fromIndex( + _userPreferences.getDevModeIndex( + UserPreferencesDevMode.userPreferencesEnumScanMode, + ), + ), + ); _controller?.addListener(_cameraListener); + // Restart the subscription if necessary + if (_streamSubscription?.isPaused == true) { + _streamSubscription!.resume(); + } else { + _subject + .throttleTime( + Duration( + milliseconds: + _averageProcessingTime.average(_defaultProcessingTime) * + _processingTimeWindows, + ), + ) + .asyncMap((CameraImage image) async { + final DateTime start = DateTime.now(); + + final List? res = + await _barcodeDecoder?.processImage(image); + + _averageProcessingTime.add( + DateTime.now().difference(start).inMilliseconds, + ); + + return res; + }) + .where( + (List? barcodes) => barcodes?.isNotEmpty == true, + ) + .cast>() + .listen(_onNewBarcodeDetected); + } + try { await _controller?.initialize(); await _controller?.setFocusMode(FocusMode.auto); await _controller?.setFocusPoint(_focusPoint); await _controller?.lockCaptureOrientation(DeviceOrientation.portraitUp); - await _controller?.startImageStream(_processCameraImage); + await _controller?.startImageStream( + (CameraImage image) => _subject.add(image), + ); } on CameraException catch (e) { if (kDebugMode) { // TODO(M123): Show error message @@ -165,46 +225,53 @@ class MLKitScannerPageState extends State<_MLKitScannerPageContent> { } } - if (mounted) { - setState(() {}); + _redrawScreen(); + } + + Future _onNewBarcodeDetected(List barcodes) async { + for (final String barcode in barcodes) { + if (await _model.onScan(barcode)) { + HapticFeedback.lightImpact(); + } } } void _cameraListener() { - if (mounted) { - setState(() {}); - - if (_controller?.value.hasError == true) { - // TODO(M123): Handle errors better - debugPrint(_controller!.value.errorDescription); - } + if (_controller?.value.hasError == true) { + // TODO(M123): Handle errors better + debugPrint(_controller!.value.errorDescription); } } - /// Stop the camera feed - /// [fromDispose] allows us to know if we can call [setState] - Future _stopImageStream({ - bool fromDispose = false, - }) async { + Future _stopImageStream({bool fromPauseEvent = false}) async { if (stoppingCamera) { return; } stoppingCamera = true; - if (mounted && !fromDispose) { - setState(() {}); - } + _redrawScreen(); _controller?.removeListener(_cameraListener); + + if (fromPauseEvent) { + _streamSubscription?.pause(); + } else { + await _streamSubscription?.cancel(); + } + await _controller?.dispose(); - await barcodeScanner?.close(); + await _barcodeDecoder?.dispose(); - barcodeScanner = null; + _barcodeDecoder = null; _controller = null; _restartCameraIfNecessary(); } + void _redrawScreen() { + setStateSafe(() {}); + } + /// The camera is fully closed at this step. /// However, the user may have "reopened" this Widget during this /// operation. In this case, [_startLiveFeed] should be called. @@ -238,33 +305,10 @@ class MLKitScannerPageState extends State<_MLKitScannerPageContent> { void dispose() { // /!\ This call is a Future, which may leads to some issues. // This should be handled by [_restartCameraIfNecessary] - _stopImageStream(fromDispose: true); + _stopImageStream(fromPauseEvent: false); super.dispose(); } - // Convert the [CameraImage] to a [InputImage] and checking this for barcodes - // with help from ML Kit - Future _processCameraImage(CameraImage image) async { - //Only scanning every xth image, but not resetting until the current one - //is done, so that we don't have idle time when the scanning takes longer - // TODO(M123): Can probably be merged with isBusy + checking if we should - // Count when ML Kit is busy - if (frameCounter < _SKIPPED_FRAMES) { - frameCounter++; - return; - } - - if (isBusy || barcodeScanner == null) { - return; - } - isBusy = true; - frameCounter = 0; - - await _scan(image); - - isBusy = false; - } - /// Whatever the scan mode is, we always want the focus point to be on /// "half-top" of the screen Offset get _focusPoint { @@ -276,50 +320,4 @@ class MLKitScannerPageState extends State<_MLKitScannerPageContent> { return Offset(0.5, 0.25 / _previewScale); } } - - Future _scan(final CameraImage image) async { - final DevModeScanMode scanMode = DevModeScanModeExtension.fromIndex( - _userPreferences - .getDevModeIndex(UserPreferencesDevMode.userPreferencesEnumScanMode), - ); - - final AbstractCameraImageGetter getter; - switch (scanMode) { - case DevModeScanMode.CAMERA_ONLY: - return; - case DevModeScanMode.PREPROCESS_FULL_IMAGE: - case DevModeScanMode.SCAN_FULL_IMAGE: - getter = CameraImageFullGetter(image, _camera!); - break; - case DevModeScanMode.PREPROCESS_HALF_IMAGE: - case DevModeScanMode.SCAN_HALF_IMAGE: - getter = CameraImageCropper( - image, - _camera!, - left01: 0, - top01: 0, - width01: 1, - height01: .5, - ); - break; - } - final InputImage inputImage = getter.getInputImage(); - - switch (scanMode) { - case DevModeScanMode.CAMERA_ONLY: - case DevModeScanMode.PREPROCESS_FULL_IMAGE: - case DevModeScanMode.PREPROCESS_HALF_IMAGE: - return; - case DevModeScanMode.SCAN_FULL_IMAGE: - case DevModeScanMode.SCAN_HALF_IMAGE: - break; - } - final List barcodes = - await barcodeScanner!.processImage(inputImage); - - for (final Barcode barcode in barcodes) { - _model - .onScan(barcode.value.rawValue); // TODO(monsieurtanuki): add "await"? - } - } } diff --git a/packages/smooth_app/lib/widgets/lifecycle_aware_widget.dart b/packages/smooth_app/lib/widgets/lifecycle_aware_widget.dart new file mode 100644 index 00000000000..7ed8f223ff0 --- /dev/null +++ b/packages/smooth_app/lib/widgets/lifecycle_aware_widget.dart @@ -0,0 +1,85 @@ +import 'package:flutter/widgets.dart'; + +abstract class LifecycleAwareStatefulWidget extends StatefulWidget { + /// Initializes [key] for subclasses. + const LifecycleAwareStatefulWidget({ + Key? key, + }) : super(key: key); + + @override + StatefulElement createElement() { + return _LifecycleAwareStatefulElement(this); + } +} + +class _LifecycleAwareStatefulElement extends StatefulElement { + _LifecycleAwareStatefulElement(StatefulWidget widget) : super(widget); + + @override + LifecycleAwareState get state => + super.state as LifecycleAwareState; + + @override + void mount(Element? parent, Object? newSlot) { + state._debugLifecycleState = StateLifecycle.initialized; + super.mount(parent, newSlot); + } + + @override + void unmount() { + state._debugLifecycleState = StateLifecycle.defunct; + super.unmount(); + } +} + +/// Make the private [_debugLifecycleState] attribute from the [State] +/// accessible +abstract class LifecycleAwareState extends State { + /// The current stage in the lifecycle for this state object. + /// + /// This field is used by the framework when asserts are enabled to verify + /// that [State] objects move through their lifecycle in an orderly fashion. + StateLifecycle _debugLifecycleState = StateLifecycle.created; + + @override + @mustCallSuper + void initState() { + _debugLifecycleState = StateLifecycle.created; + super.initState(); + } + + @override + @mustCallSuper + void didChangeDependencies() { + super.didChangeDependencies(); + _debugLifecycleState = StateLifecycle.ready; + } + + /// Will call [setState] only if the current lifecycle state allows it + void setStateSafe(VoidCallback fn) { + if (_debugLifecycleState != StateLifecycle.defunct) { + setState(fn); + } + } + + StateLifecycle get lifecycleState => _debugLifecycleState; +} + +/// Extracted from [State] class +enum StateLifecycle { + /// The [State] object has been created. [State.initState] is called at this + /// time. + created, + + /// The [State.initState] method has been called but the [State] object is + /// not yet ready to build. [State.didChangeDependencies] is called at this time. + initialized, + + /// The [State] object is ready to build and [State.dispose] has not yet been + /// called. + ready, + + /// The [State.dispose] method has been called and the [State] object is + /// no longer able to build. + defunct, +} diff --git a/packages/smooth_app/pubspec.lock b/packages/smooth_app/pubspec.lock index 0a8d3266216..3fe1730e83f 100644 --- a/packages/smooth_app/pubspec.lock +++ b/packages/smooth_app/pubspec.lock @@ -77,7 +77,7 @@ packages: name: built_value url: "https://pub.dartlang.org" source: hosted - version: "8.2.3" + version: "8.3.0" camera: dependency: "direct main" description: @@ -235,6 +235,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_isolate: + dependency: "direct main" + description: + name: flutter_isolate + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" flutter_launcher_icons: dependency: "direct dev" description: @@ -864,6 +871,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + rxdart: + dependency: "direct main" + description: + name: rxdart + url: "https://pub.dartlang.org" + source: hosted + version: "0.27.3" sentry: dependency: transitive description: diff --git a/packages/smooth_app/pubspec.yaml b/packages/smooth_app/pubspec.yaml index 5cbaa123f02..5e558f0f98c 100644 --- a/packages/smooth_app/pubspec.yaml +++ b/packages/smooth_app/pubspec.yaml @@ -37,11 +37,13 @@ dependencies: uuid: ^3.0.6 provider: ^6.0.2 qr_code_scanner: ^0.7.0 + rxdart: ^0.27.3 + flutter_isolate: ^2.0.2 rubber: ^1.0.1 sentry_flutter: ^6.5.1 # careful with upgrading cf: https://github.com/openfoodfacts/smooth-app/issues/1300 url_launcher: ^6.1.0 visibility_detector: ^0.2.2 - camera: ^0.9.4+20 + camera: ^0.9.4+21 percent_indicator: ^4.0.1 mailto: ^2.0.0 flutter_native_splash: ^2.1.6