Navaron Bracke

refactor the MobileScanner widget to remove the lifecycle handling in the widget

1 import 'dart:async'; 1 import 'dart:async';
2 2
3 -import 'package:flutter/foundation.dart';  
4 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
5 -import 'package:flutter/services.dart';  
6 -import 'package:mobile_scanner/src/enums/mobile_scanner_error_code.dart';  
7 import 'package:mobile_scanner/src/mobile_scanner_controller.dart'; 4 import 'package:mobile_scanner/src/mobile_scanner_controller.dart';
8 import 'package:mobile_scanner/src/mobile_scanner_exception.dart'; 5 import 'package:mobile_scanner/src/mobile_scanner_exception.dart';
9 -import 'package:mobile_scanner/src/objects/barcode_capture.dart';  
10 -import 'package:mobile_scanner/src/objects/mobile_scanner_arguments.dart'; 6 +import 'package:mobile_scanner/src/mobile_scanner_platform_interface.dart';
  7 +import 'package:mobile_scanner/src/objects/mobile_scanner_state.dart';
11 import 'package:mobile_scanner/src/scan_window_calculation.dart'; 8 import 'package:mobile_scanner/src/scan_window_calculation.dart';
12 9
13 /// The function signature for the error builder. 10 /// The function signature for the error builder.
@@ -17,18 +14,26 @@ typedef MobileScannerErrorBuilder = Widget Function( @@ -17,18 +14,26 @@ typedef MobileScannerErrorBuilder = Widget Function(
17 Widget?, 14 Widget?,
18 ); 15 );
19 16
20 -/// The [MobileScanner] widget displays a live camera preview. 17 +/// This widget displays a live camera preview for the barcode scanner.
21 class MobileScanner extends StatefulWidget { 18 class MobileScanner extends StatefulWidget {
22 - /// The controller that manages the barcode scanner.  
23 - ///  
24 - /// If this is null, the scanner will manage its own controller.  
25 - final MobileScannerController? controller; 19 + /// Create a new [MobileScanner] using the provided [controller].
  20 + const MobileScanner({
  21 + required this.controller,
  22 + this.fit = BoxFit.cover,
  23 + this.errorBuilder,
  24 + this.overlayBuilder,
  25 + this.placeholderBuilder,
  26 + this.scanWindow,
  27 + super.key,
  28 + });
  29 +
  30 + /// The controller for the camera preview.
  31 + final MobileScannerController controller;
26 32
27 - /// The function that builds an error widget when the scanner  
28 - /// could not be started. 33 + /// The error builder for the camera preview.
29 /// 34 ///
30 - /// If this is null, defaults to a black [ColoredBox]  
31 - /// with a centered white [Icons.error] icon. 35 + /// If this is null, a black [ColoredBox],
  36 + /// with a centered white [Icons.error] icon is used as error widget.
32 final MobileScannerErrorBuilder? errorBuilder; 37 final MobileScannerErrorBuilder? errorBuilder;
33 38
34 /// The [BoxFit] for the camera preview. 39 /// The [BoxFit] for the camera preview.
@@ -36,250 +41,123 @@ class MobileScanner extends StatefulWidget { @@ -36,250 +41,123 @@ class MobileScanner extends StatefulWidget {
36 /// Defaults to [BoxFit.cover]. 41 /// Defaults to [BoxFit.cover].
37 final BoxFit fit; 42 final BoxFit fit;
38 43
39 - /// The function that signals when new codes were detected by the [controller].  
40 - final void Function(BarcodeCapture barcodes) onDetect;  
41 -  
42 - /// The function that signals when the barcode scanner is started.  
43 - @Deprecated('Use onScannerStarted() instead.')  
44 - final void Function(MobileScannerArguments? arguments)? onStart;  
45 -  
46 - /// The function that signals when the barcode scanner is started.  
47 - final void Function(MobileScannerArguments? arguments)? onScannerStarted; 44 + /// The builder for the overlay above the camera preview.
  45 + ///
  46 + /// The resulting widget can be combined with the [scanWindow] rectangle
  47 + /// to create a cutout for the camera preview.
  48 + ///
  49 + /// The [BoxConstraints] for this builder
  50 + /// are the same constraints that are used to compute the effective [scanWindow].
  51 + ///
  52 + /// The overlay is only displayed when the camera preview is visible.
  53 + final LayoutWidgetBuilder? overlayBuilder;
48 54
49 - /// The function that builds a placeholder widget when the scanner  
50 - /// is not yet displaying its camera preview. 55 + /// The placeholder builder for the camera preview.
51 /// 56 ///
52 /// If this is null, a black [ColoredBox] is used as placeholder. 57 /// If this is null, a black [ColoredBox] is used as placeholder.
  58 + ///
  59 + /// The placeholder is displayed when the camera preview is being initialized.
53 final Widget Function(BuildContext, Widget?)? placeholderBuilder; 60 final Widget Function(BuildContext, Widget?)? placeholderBuilder;
54 61
55 - /// if set barcodes will only be scanned if they fall within this [Rect]  
56 - /// useful for having a cut-out overlay for example. these [Rect]  
57 - /// coordinates are relative to the widget size, so by how much your  
58 - /// rectangle overlays the actual image can depend on things like the  
59 - /// [BoxFit]  
60 - final Rect? scanWindow;  
61 -  
62 - /// Only set this to true if you are starting another instance of mobile_scanner  
63 - /// right after disposing the first one, like in a PageView. 62 + /// The scan window rectangle for the barcode scanner.
64 /// 63 ///
65 - /// Default: false  
66 - final bool startDelay;  
67 -  
68 - /// The overlay which will be painted above the scanner when has started successful.  
69 - /// Will no be pointed when an error occurs or the scanner hasn't been started yet.  
70 - final Widget? overlay;  
71 -  
72 - /// Create a new [MobileScanner] using the provided [controller]  
73 - /// and [onBarcodeDetected] callback.  
74 - const MobileScanner({  
75 - this.controller,  
76 - this.errorBuilder,  
77 - this.fit = BoxFit.cover,  
78 - required this.onDetect,  
79 - @Deprecated('Use onScannerStarted() instead.') this.onStart,  
80 - this.onScannerStarted,  
81 - this.placeholderBuilder,  
82 - this.scanWindow,  
83 - this.startDelay = false,  
84 - this.overlay,  
85 - super.key,  
86 - }); 64 + /// If this is not null, the barcode scanner will only scan barcodes
  65 + /// which intersect this rectangle.
  66 + ///
  67 + /// The rectangle is relative to the layout size of the *camera preview widget*,
  68 + /// rather than the actual camera preview size,
  69 + /// since the actual widget size might not be the same as the camera preview size.
  70 + ///
  71 + /// For example, the applied [fit] has an effect on the size of the camera preview widget,
  72 + /// while the camera preview size remains the same.
  73 + final Rect? scanWindow;
87 74
88 @override 75 @override
89 State<MobileScanner> createState() => _MobileScannerState(); 76 State<MobileScanner> createState() => _MobileScannerState();
90 } 77 }
91 78
92 -class _MobileScannerState extends State<MobileScanner>  
93 - with WidgetsBindingObserver {  
94 - /// The subscription that listens to barcode detection.  
95 - StreamSubscription<BarcodeCapture>? _barcodesSubscription;  
96 -  
97 - /// The internally managed controller.  
98 - late MobileScannerController _controller;  
99 -  
100 - /// Whether the controller should resume  
101 - /// when the application comes back to the foreground.  
102 - bool _resumeFromBackground = false;  
103 -  
104 - MobileScannerException? _startException;  
105 -  
106 - Widget _buildPlaceholderOrError(BuildContext context, Widget? child) {  
107 - final error = _startException; 79 +class _MobileScannerState extends State<MobileScanner> {
  80 + /// The current scan window.
  81 + Rect? scanWindow;
108 82
109 - if (error != null) {  
110 - return widget.errorBuilder?.call(context, error, child) ??  
111 - const ColoredBox(  
112 - color: Colors.black,  
113 - child: Center(child: Icon(Icons.error, color: Colors.white)), 83 + /// Recalculate the scan window based on the updated [constraints].
  84 + void _maybeUpdateScanWindow(MobileScannerState scannerState, BoxConstraints constraints) {
  85 + if (widget.scanWindow != null && scanWindow == null) {
  86 + scanWindow = calculateScanWindowRelativeToTextureInPercentage(
  87 + widget.fit,
  88 + widget.scanWindow!,
  89 + textureSize: scannerState.size,
  90 + widgetSize: constraints.biggest,
114 ); 91 );
115 - }  
116 92
117 - return widget.placeholderBuilder?.call(context, child) ??  
118 - const ColoredBox(color: Colors.black); 93 + unawaited(widget.controller.updateScanWindow(scanWindow));
119 } 94 }
120 -  
121 - /// Start the given [scanner].  
122 - Future<void> _startScanner() async {  
123 - if (widget.startDelay) {  
124 - await Future.delayed(const Duration(seconds: 1, milliseconds: 500));  
125 } 95 }
126 96
127 - _barcodesSubscription ??= _controller.barcodes.listen(  
128 - widget.onDetect,  
129 - ); 97 + @override
  98 + Widget build(BuildContext context) {
  99 + return ValueListenableBuilder<MobileScannerState>(
  100 + valueListenable: widget.controller,
  101 + builder: (BuildContext context, MobileScannerState value, Widget? child) {
  102 + if (!value.isInitialized) {
  103 + const Widget defaultPlaceholder = ColoredBox(color: Colors.black);
130 104
131 - if (!_controller.autoStart) {  
132 - debugPrint(  
133 - 'mobile_scanner: not starting automatically because autoStart is set to false in the controller.',  
134 - );  
135 - return; 105 + return widget.placeholderBuilder?.call(context, child) ?? defaultPlaceholder;
136 } 106 }
137 107
138 - _controller.start().then((arguments) {  
139 - // ignore: deprecated_member_use_from_same_package  
140 - widget.onStart?.call(arguments);  
141 - widget.onScannerStarted?.call(arguments);  
142 - }).catchError((error) {  
143 - if (!mounted) {  
144 - return;  
145 - } 108 + final MobileScannerException? error = value.error;
146 109
147 - if (error is MobileScannerException) {  
148 - _startException = error;  
149 - } else if (error is PlatformException) {  
150 - _startException = MobileScannerException(  
151 - errorCode: MobileScannerErrorCode.genericError,  
152 - errorDetails: MobileScannerErrorDetails(  
153 - code: error.code,  
154 - message: error.message,  
155 - details: error.details,  
156 - ),  
157 - );  
158 - } else {  
159 - _startException = MobileScannerException(  
160 - errorCode: MobileScannerErrorCode.genericError,  
161 - errorDetails: MobileScannerErrorDetails(  
162 - details: error,  
163 - ), 110 + if (error != null) {
  111 + const Widget defaultError = ColoredBox(
  112 + color: Colors.black,
  113 + child: Center(child: Icon(Icons.error, color: Colors.white)),
164 ); 114 );
165 - }  
166 115
167 - setState(() {});  
168 - }); 116 + return widget.errorBuilder?.call(context, error, child) ?? defaultError;
169 } 117 }
170 118
171 - @override  
172 - void initState() {  
173 - super.initState();  
174 - WidgetsBinding.instance.addObserver(this);  
175 - _controller = widget.controller ?? MobileScannerController();  
176 - _startScanner();  
177 - }  
178 -  
179 - @override  
180 - void didChangeAppLifecycleState(AppLifecycleState state) {  
181 - // App state changed before the controller was initialized.  
182 - if (_controller.isStarting) {  
183 - return;  
184 - }  
185 -  
186 - switch (state) {  
187 - case AppLifecycleState.resumed:  
188 - if (_resumeFromBackground) {  
189 - _startScanner();  
190 - }  
191 - case AppLifecycleState.inactive:  
192 - _resumeFromBackground = true;  
193 - _controller.stop();  
194 - default:  
195 - break;  
196 - }  
197 - }  
198 -  
199 - Rect? scanWindow;  
200 -  
201 - @override  
202 - Widget build(BuildContext context) {  
203 return LayoutBuilder( 119 return LayoutBuilder(
204 builder: (context, constraints) { 120 builder: (context, constraints) {
205 - return ValueListenableBuilder<MobileScannerArguments?>(  
206 - valueListenable: _controller.startArguments,  
207 - builder: (context, value, child) {  
208 - if (value == null) {  
209 - return _buildPlaceholderOrError(context, child);  
210 - } 121 + _maybeUpdateScanWindow(value, constraints);
211 122
212 - if (widget.scanWindow != null && scanWindow == null) {  
213 - scanWindow = calculateScanWindowRelativeToTextureInPercentage(  
214 - widget.fit,  
215 - widget.scanWindow!,  
216 - textureSize: value.size,  
217 - widgetSize: constraints.biggest, 123 + final Widget? overlay = widget.overlayBuilder?.call(context, constraints);
  124 + final Size cameraPreviewSize = value.size;
  125 +
  126 + final Widget scannerWidget = ClipRect(
  127 + child: SizedBox.fromSize(
  128 + size: constraints.biggest,
  129 + child: FittedBox(
  130 + fit: widget.fit,
  131 + child: SizedBox(
  132 + width: cameraPreviewSize.width,
  133 + height: cameraPreviewSize.height,
  134 + child: MobileScannerPlatform.instance.buildCameraView(),
  135 + ),
  136 + ),
  137 + ),
218 ); 138 );
219 139
220 - _controller.updateScanWindow(scanWindow); 140 + if (overlay == null) {
  141 + return scannerWidget;
221 } 142 }
222 - if (widget.overlay != null) { 143 +
223 return Stack( 144 return Stack(
224 alignment: Alignment.center, 145 alignment: Alignment.center,
225 - children: [  
226 - _scanner(  
227 - value.size,  
228 - value.webId,  
229 - value.textureId,  
230 - value.numberOfCameras,  
231 - ),  
232 - widget.overlay!, 146 + children: <Widget>[
  147 + scannerWidget,
  148 + overlay,
233 ], 149 ],
234 ); 150 );
235 - } else {  
236 - return _scanner(  
237 - value.size,  
238 - value.webId,  
239 - value.textureId,  
240 - value.numberOfCameras,  
241 - );  
242 - }  
243 }, 151 },
244 ); 152 );
245 }, 153 },
246 ); 154 );
247 } 155 }
248 156
249 - Widget _scanner(  
250 - Size size,  
251 - String? webId,  
252 - int? textureId,  
253 - int? numberOfCameras,  
254 - ) {  
255 - return ClipRect(  
256 - child: LayoutBuilder(  
257 - builder: (_, constraints) {  
258 - return SizedBox.fromSize(  
259 - size: constraints.biggest,  
260 - child: FittedBox(  
261 - fit: widget.fit,  
262 - child: SizedBox(  
263 - width: size.width,  
264 - height: size.height,  
265 - child: kIsWeb  
266 - ? HtmlElementView(viewType: webId!)  
267 - : Texture(textureId: textureId!),  
268 - ),  
269 - ),  
270 - );  
271 - },  
272 - ),  
273 - );  
274 - }  
275 -  
276 @override 157 @override
277 void dispose() { 158 void dispose() {
278 - _controller.updateScanWindow(null);  
279 - WidgetsBinding.instance.removeObserver(this);  
280 - _barcodesSubscription?.cancel();  
281 - _barcodesSubscription = null;  
282 - _controller.dispose(); 159 + // When this widget is unmounted, reset the scan window.
  160 + widget.controller.updateScanWindow(null);
283 super.dispose(); 161 super.dispose();
284 } 162 }
285 } 163 }