From e30ab6b1797fef793ba42e10e36f13297d13c354 Mon Sep 17 00:00:00 2001 From: Marvin M <39344769+M123-dev@users.noreply.github.com> Date: Sat, 15 Jan 2022 16:30:02 +0100 Subject: [PATCH] Refactor: Extracted camera overlays into their own widget with visibility management (#949) * Refactor: Camera refactoring * Update ml_kit_scan_page.dart * Update ml_kit_scan_page.dart * added documentation and lifecycle management * Update ml_kit_scan_page.dart --- .../lib/pages/scan/continuous_scan_page.dart | 73 +++----- .../lib/pages/scan/ml_kit_scan_page.dart | 94 ++++------- .../lib/pages/scan/scan_page_helper.dart | 73 -------- .../lib/pages/scan/scanner_overlay.dart | 159 ++++++++++++++++++ 4 files changed, 216 insertions(+), 183 deletions(-) create mode 100644 packages/smooth_app/lib/pages/scan/scanner_overlay.dart diff --git a/packages/smooth_app/lib/pages/scan/continuous_scan_page.dart b/packages/smooth_app/lib/pages/scan/continuous_scan_page.dart index 169052b873d..2b9f3b1eafe 100644 --- a/packages/smooth_app/lib/pages/scan/continuous_scan_page.dart +++ b/packages/smooth_app/lib/pages/scan/continuous_scan_page.dart @@ -2,9 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:qr_code_scanner/qr_code_scanner.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; -import 'package:smooth_app/pages/scan/scan_page_helper.dart'; -import 'package:smooth_ui_library/smooth_ui_library.dart'; -import 'package:visibility_detector/visibility_detector.dart'; +import 'package:smooth_app/pages/scan/scanner_overlay.dart'; class ContinuousScanPage extends StatefulWidget { const ContinuousScanPage(); @@ -21,55 +19,30 @@ class _ContinuousScanPageState extends State { @override Widget build(BuildContext context) { _model = context.watch(); - return VisibilityDetector( - key: const Key('VisibilityDetector qr_code_scanner'), - onVisibilityChanged: (VisibilityInfo visibilityInfo) { - if (visibilityInfo.visibleFraction == 0.0) { - _stopLiveFeed(); - } else { - _resumeLiveFeed(); - } - }, - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final double carouselHeight = constraints.maxHeight / - 1.81; // roughly 55% of the available height - final double viewFinderBottomOffset = carouselHeight / 2.0; - - final List children = getScannerWidgets( - context, - constraints, - _model, - ); - - //Insert scanner at the right position - children.insert( - 1, - SmoothRevealAnimation( - delay: 400, - startOffset: Offset.zero, - animationCurve: Curves.easeInOutBack, - child: QRView( - overlay: QrScannerOverlayShape( - // We use [SmoothViewFinder] instead of the overlay. - overlayColor: Colors.transparent, - // This offset adjusts the scanning area on iOS. - cutOutBottomOffset: viewFinderBottomOffset, - ), - key: _scannerViewKey, - onQRViewCreated: setupScanner, + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double carouselHeight = + constraints.maxHeight / 1.81; // roughly 55% of the available height + final double viewFinderBottomOffset = carouselHeight / 2.0; + + return Scaffold( + body: ScannerOverlay( + model: _model, + restartCamera: _resumeLiveFeed, + stopCamera: _stopLiveFeed, + scannerWidget: QRView( + overlay: QrScannerOverlayShape( + // We use [SmoothViewFinder] instead of the overlay. + overlayColor: Colors.transparent, + // This offset adjusts the scanning area on iOS. + cutOutBottomOffset: viewFinderBottomOffset, ), + key: _scannerViewKey, + onQRViewCreated: setupScanner, ), - ); - - return Scaffold( - appBar: AppBar(toolbarHeight: 0.0), - body: Stack( - children: children, - ), - ); - }, - ), + ), + ); + }, ); } 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 66978865296..b1658e84364 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 @@ -8,9 +8,7 @@ import 'package:google_ml_barcode_scanner/google_ml_barcode_scanner.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; import 'package:smooth_app/main.dart'; -import 'package:smooth_app/pages/scan/scan_page_helper.dart'; -import 'package:smooth_ui_library/animations/smooth_reveal_animation.dart'; -import 'package:visibility_detector/visibility_detector.dart'; +import 'package:smooth_app/pages/scan/scanner_overlay.dart'; class MLKitScannerPage extends StatefulWidget { const MLKitScannerPage({Key? key}) : super(key: key); @@ -26,6 +24,7 @@ class MLKitScannerPageState extends State { int _cameraIndex = 0; CameraLensDirection cameraLensDirection = CameraLensDirection.back; bool isBusy = false; + bool imageStreamActive = false; @override void initState() { @@ -59,31 +58,16 @@ class MLKitScannerPageState extends State { @override void dispose() { - _stopLiveFeed(); - _disposeLiveFeed(); + _stopImageStream().then( + (_) => _controller?.dispose(), + ); super.dispose(); } @override Widget build(BuildContext context) { _model = context.watch(); - return Scaffold( - body: VisibilityDetector( - key: const Key('VisibilityDetector ML Kit'), - onVisibilityChanged: (VisibilityInfo visibilityInfo) { - if (visibilityInfo.visibleFraction == 0.0) { - _stopLiveFeed(); - } else { - _startLiveFeed(); - } - }, - child: _liveFeedBody(), - ), - ); - } - - Widget _liveFeedBody() { - if (_controller?.value.isInitialized == false || _controller == null) { + if (_controller == null || _controller!.value.isInitialized == false) { return const Center(child: CircularProgressIndicator()); } @@ -100,37 +84,20 @@ class MLKitScannerPageState extends State { scale = 1 / scale; } - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - final List children = getScannerWidgets( - context, - constraints, - _model, - ); - - //Inserting the scanner at the right position - children.insert( - 1, - SmoothRevealAnimation( - delay: 400, - startOffset: Offset.zero, - animationCurve: Curves.easeInOutBack, - child: Transform.scale( - scale: scale, - child: Center( - child: CameraPreview( - _controller!, - ), - ), + return Scaffold( + body: ScannerOverlay( + restartCamera: _resumeImageStream, + stopCamera: _stopImageStream, + model: _model, + scannerWidget: Transform.scale( + scale: scale, + child: Center( + child: CameraPreview( + _controller!, ), ), - ); - - return Stack( - fit: StackFit.expand, - children: children, - ); - }, + ), + ), ); } @@ -141,24 +108,31 @@ class MLKitScannerPageState extends State { ResolutionPreset.high, enableAudio: false, ); - _controller?.initialize().then((_) { + _controller!.setFocusMode(FocusMode.auto); + _controller!.lockCaptureOrientation(DeviceOrientation.portraitUp); + + _controller!.initialize().then((_) { if (!mounted) { return; } - _controller?.setFocusMode(FocusMode.auto); - _controller?.lockCaptureOrientation(DeviceOrientation.portraitUp); - _controller?.startImageStream(_processCameraImage); + _controller!.startImageStream(_processCameraImage); + imageStreamActive = true; setState(() {}); }); } - Future _stopLiveFeed() async { - await _controller?.stopImageStream(); - _controller = null; + void _resumeImageStream() { + if (_controller != null && !imageStreamActive) { + _controller!.startImageStream(_processCameraImage); + imageStreamActive = true; + } } - Future _disposeLiveFeed() async { - await _controller?.dispose(); + Future _stopImageStream() async { + if (_controller != null) { + await _controller!.stopImageStream(); + imageStreamActive = false; + } } //Convert the [CameraImage] to a [InputImage] diff --git a/packages/smooth_app/lib/pages/scan/scan_page_helper.dart b/packages/smooth_app/lib/pages/scan/scan_page_helper.dart index 6d4ba383c53..5e433916e57 100644 --- a/packages/smooth_app/lib/pages/scan/scan_page_helper.dart +++ b/packages/smooth_app/lib/pages/scan/scan_page_helper.dart @@ -1,14 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:provider/provider.dart'; import 'package:smooth_app/data_models/continuous_scan_model.dart'; import 'package:smooth_app/pages/personalized_ranking_page.dart'; import 'package:smooth_app/widgets/ranking_floating_action_button.dart'; -import 'package:smooth_app/widgets/smooth_product_carousel.dart'; -import 'package:smooth_ui_library/animations/smooth_reveal_animation.dart'; import 'package:smooth_ui_library/util/ui_helpers.dart'; -import 'package:smooth_ui_library/widgets/smooth_view_finder.dart'; bool areButtonsRendered(ContinuousScanModel model) => model.hasMoreThanOneProduct; @@ -68,72 +64,3 @@ Widget buildButtonsRow(BuildContext context, ContinuousScanModel model) { ), ); } - -List getScannerWidgets( - BuildContext context, - BoxConstraints constraints, - ContinuousScanModel model, -) { - final Size screenSize = MediaQuery.of(context).size; - final Size scannerSize = Size( - screenSize.width * 0.6, - screenSize.width * 0.33, - ); - final double carouselHeight = - constraints.maxHeight / 1.81; // roughly 55% of the available height - final double buttonRowHeight = areButtonsRendered(model) ? 48 : 0; - final double availableScanHeight = - constraints.maxHeight - carouselHeight - buttonRowHeight; - // Padding for the qr code scanner. This ensures the scanner has equal spacing between buttons and carousel. - final EdgeInsets qrScannerPadding = EdgeInsets.only( - top: (availableScanHeight - scannerSize.height) / 2 + buttonRowHeight); - - return [ - Container( - alignment: Alignment.center, - color: Colors.black, - child: Padding( - padding: qrScannerPadding, - child: SvgPicture.asset( - 'assets/actions/scanner_alt_2.svg', - width: scannerSize.width, - height: scannerSize.height, - color: Colors.white, - ), - ), - ), - SmoothRevealAnimation( - delay: 400, - startOffset: const Offset(0.0, 0.1), - animationCurve: Curves.easeInOutBack, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Padding( - padding: qrScannerPadding, - child: SmoothViewFinder( - boxSize: scannerSize, - lineLength: screenSize.width * 0.8, - ), - ), - ], - ), - ), - SmoothRevealAnimation( - delay: 400, - startOffset: const Offset(0.0, -0.1), - animationCurve: Curves.easeInOutBack, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - buildButtonsRow(context, model), - const Spacer(), - SmoothProductCarousel( - showSearchCard: true, - height: carouselHeight, - ), - ], - ), - ), - ]; -} diff --git a/packages/smooth_app/lib/pages/scan/scanner_overlay.dart b/packages/smooth_app/lib/pages/scan/scanner_overlay.dart new file mode 100644 index 00000000000..70f7ad1bff4 --- /dev/null +++ b/packages/smooth_app/lib/pages/scan/scanner_overlay.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:smooth_app/data_models/continuous_scan_model.dart'; +import 'package:smooth_app/pages/scan/scan_page_helper.dart'; +import 'package:smooth_app/widgets/smooth_product_carousel.dart'; +import 'package:smooth_ui_library/animations/smooth_reveal_animation.dart'; +import 'package:smooth_ui_library/widgets/smooth_view_finder.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +/// This builds all the essential widgets which are displayed above the camera +/// preview, like the [SmoothProductCarousel], the [SmoothViewFinder] and the +/// clear and compare buttons row. It takes the camera preview widget to display +/// and functions to stop and restart the camera, to only activate the camera +/// when the screen is currently visible. +class ScannerOverlay extends StatefulWidget { + const ScannerOverlay({ + required this.scannerWidget, + required this.model, + required this.restartCamera, + required this.stopCamera, + }); + + final Widget scannerWidget; + final ContinuousScanModel model; + final Function() restartCamera; + final Function() stopCamera; + + static const double carouselHeightPct = 0.55; + static const double scannerWidthPct = 0.6; + static const double scannerHeightPct = 0.33; + static const double buttonRowHeightPx = 48; + + @override + State createState() => _ScannerOverlayState(); +} + +class _ScannerOverlayState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance!.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance!.removeObserver(this); + super.dispose(); + } + + // Lifecycle changes are not handled by either of the used plugin. This means + // we are responsible to control camera resources when the lifecycle state is + // updated. Failure to do so might lead to unexpected behavior + // didChangeAppLifecycleState is called when the system puts the app in the + // background or returns the app to the foreground. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.inactive) { + widget.stopCamera.call(); + } else if (state == AppLifecycleState.resumed) { + widget.restartCamera.call(); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: ( + BuildContext context, + BoxConstraints constraints, + ) { + final Size screenSize = MediaQuery.of(context).size; + final Size scannerSize = Size( + screenSize.width * ScannerOverlay.scannerWidthPct, + screenSize.width * ScannerOverlay.scannerHeightPct, + ); + final double carouselHeight = + constraints.maxHeight * ScannerOverlay.carouselHeightPct; + final double buttonRowHeight = areButtonsRendered(widget.model) + ? ScannerOverlay.buttonRowHeightPx + : 0; + final double availableScanHeight = + constraints.maxHeight - carouselHeight - buttonRowHeight; + + // Padding for the qr code scanner. This ensures the scanner has equal spacing between buttons and carousel. + final EdgeInsets qrScannerPadding = EdgeInsets.only( + top: (availableScanHeight - scannerSize.height) / 2 + + buttonRowHeight); + + return VisibilityDetector( + key: const ValueKey('VisibilityDetector'), + onVisibilityChanged: (VisibilityInfo info) { + if (info.visibleFraction == 0.0) { + widget.stopCamera.call(); + } else { + widget.restartCamera.call(); + } + }, + child: Stack( + children: [ + Container( + alignment: Alignment.center, + color: Colors.black, + child: Padding( + padding: qrScannerPadding, + child: SvgPicture.asset( + 'assets/actions/scanner_alt_2.svg', + width: scannerSize.width * 0.8, + height: scannerSize.height * 0.8, + color: Colors.white, + ), + ), + ), + SmoothRevealAnimation( + delay: 400, + startOffset: Offset.zero, + animationCurve: Curves.easeInOutBack, + child: widget.scannerWidget, + ), + SmoothRevealAnimation( + delay: 400, + startOffset: const Offset(0.0, 0.1), + animationCurve: Curves.easeInOutBack, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: qrScannerPadding, + child: SmoothViewFinder( + boxSize: scannerSize, + lineLength: screenSize.width * 0.8, + ), + ), + ], + ), + ), + SmoothRevealAnimation( + delay: 400, + startOffset: const Offset(0.0, -0.1), + animationCurve: Curves.easeInOutBack, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + buildButtonsRow(context, widget.model), + const Spacer(), + SmoothProductCarousel( + showSearchCard: true, + height: carouselHeight, + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +}