Make mobile onboarding QR-first and synchronize scanner motion

Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-10-26 10:04:46 +00:00
parent d676c7b8db
commit cc6f37c7c7
12 changed files with 284 additions and 101 deletions

View File

@@ -466,7 +466,12 @@ import os.log
)
return
}
UIApplication.shared.setStatusBarHidden(hidden, with: .fade)
let controller = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.compactMap { $0.rootViewController as? BuzzFlutterViewController }
.first
controller?.setScannerStatusBarHidden(hidden)
result(nil)
case "performDynamicIslandQrScanSuccessHaptic":
let generator = UINotificationFeedbackGenerator()

View File

@@ -8,7 +8,7 @@
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<viewController id="BYZ-38-t0r" customClass="BuzzFlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>

View File

@@ -4,3 +4,29 @@ import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
/// Owns scanner visibility without replacing Flutter's normal status-bar policy.
@objc(BuzzFlutterViewController)
class BuzzFlutterViewController: FlutterViewController {
private var scannerHidesStatusBar = false
override var prefersStatusBarHidden: Bool {
scannerHidesStatusBar || super.prefersStatusBarHidden
}
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
UIAccessibility.isReduceMotionEnabled ? .none : .fade
}
func setScannerStatusBarHidden(_ hidden: Bool) {
guard scannerHidesStatusBar != hidden else { return }
scannerHidesStatusBar = hidden
UIView.animate(
withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.3,
delay: 0,
options: [.beginFromCurrentState, .curveEaseInOut]
) {
self.setNeedsStatusBarAppearanceUpdate()
}
}
}

View File

@@ -92,6 +92,7 @@ class PairingPage extends HookConsumerWidget {
final enrolledBiometrics = ref.watch(enrolledBiometricsProvider);
final codeController = useTextEditingController();
final fallbackScannerVisible = useState(false);
final islandScannerProgress = useValueNotifier(0.0);
final pairingCodeExpanded = useState(false);
final optionsExpanded = useState(false);
final isBusy =
@@ -137,6 +138,7 @@ class PairingPage extends HookConsumerWidget {
}
Future<void> openScanner() async {
unawaited(HapticFeedback.selectionClick());
final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal();
if (!context.mounted) {
return;
@@ -147,7 +149,17 @@ class PairingPage extends HookConsumerWidget {
return;
}
final code = await showDynamicIslandPairingQrScanner(context);
String? code;
try {
code = await showDynamicIslandPairingQrScanner(
context,
onProgress: (value) {
if (context.mounted) islandScannerProgress.value = value;
},
);
} finally {
if (context.mounted) islandScannerProgress.value = 0;
}
await handleScannerResult(code);
}
@@ -279,43 +291,66 @@ class PairingPage extends HookConsumerWidget {
backgroundColor: Colors.transparent,
appBar: pairingAppBar,
body: SafeArea(
child: _PairingWelcomeView(
optionsExpanded: optionsExpanded,
onAlreadyUseBuzz: onAlreadyUseBuzz,
onWelcomeBack: onWelcomeBack,
connectionLanding: connectionLanding,
showConnectionOptions: showConnectionOptions,
existingUser: existingUser,
enteringCommunityLink: enteringCommunityLink,
creatingCommunity: creatingCommunity,
onSubmitCommunity: onSubmitCommunity,
onCommunityLink: onCommunityLink,
onCreateCommunity: onCreateCommunity,
onOpenCommunity: (uri) {
FocusManager.instance.primaryFocus?.unfocus();
ref.read(pendingDeepLinkProvider.notifier).open(uri);
},
onBuilderlab: onBuilderlab,
accountStatus: accountStatus,
setupContent: setupContent,
builderlabLabel: builderlabLabel,
builderlabBusy: builderlabBusy,
codeController: codeController,
isBusy: isBusy,
pairingCodeExpanded: pairingCodeExpanded.value,
errorMessage: pairingState.status == PairingStatus.error
? pairingState.errorMessage
: null,
onScan: openScanner,
onTogglePairingCode: () {
pairingCodeExpanded.value = !pairingCodeExpanded.value;
},
onConnect: () {
final code = codeController.text.trim();
if (code.isNotEmpty) {
unawaited(handleScannerResult(code));
}
},
child: ValueListenableBuilder<double>(
valueListenable: islandScannerProgress,
builder: (context, progress, child) => ExcludeFocus(
excluding: progress > 0,
child: ExcludeSemantics(
excluding: progress > 0,
child: IgnorePointer(
ignoring: progress > 0,
child: Opacity(
key: const Key('scanner-welcome-opacity'),
opacity: 1 - progress,
child: Transform.scale(
key: const Key('scanner-welcome-scale'),
scale: MediaQuery.disableAnimationsOf(context)
? 1
: 1 - 0.04 * progress,
child: child,
),
),
),
),
),
child: _PairingWelcomeView(
optionsExpanded: optionsExpanded,
onAlreadyUseBuzz: onAlreadyUseBuzz,
onWelcomeBack: onWelcomeBack,
connectionLanding: connectionLanding,
showConnectionOptions: showConnectionOptions,
existingUser: existingUser,
enteringCommunityLink: enteringCommunityLink,
creatingCommunity: creatingCommunity,
onSubmitCommunity: onSubmitCommunity,
onCommunityLink: onCommunityLink,
onCreateCommunity: onCreateCommunity,
onOpenCommunity: (uri) {
FocusManager.instance.primaryFocus?.unfocus();
ref.read(pendingDeepLinkProvider.notifier).open(uri);
},
onBuilderlab: onBuilderlab,
accountStatus: accountStatus,
setupContent: setupContent,
builderlabLabel: builderlabLabel,
builderlabBusy: builderlabBusy,
codeController: codeController,
isBusy: isBusy,
pairingCodeExpanded: pairingCodeExpanded.value,
errorMessage: pairingState.status == PairingStatus.error
? pairingState.errorMessage
: null,
onScan: openScanner,
onTogglePairingCode: () {
pairingCodeExpanded.value = !pairingCodeExpanded.value;
},
onConnect: () {
final code = codeController.text.trim();
if (code.isNotEmpty) {
unawaited(handleScannerResult(code));
}
},
),
),
),
),

View File

@@ -7,7 +7,8 @@ class _OnboardingConnectionView extends HookWidget {
required this.onCommunityLink,
required this.onBack,
required this.onOpenCommunity,
required this.onScan,
required this.onBuilderlab,
required this.builderlabLabel,
required this.onPairing,
});
@@ -15,7 +16,8 @@ class _OnboardingConnectionView extends HookWidget {
final VoidCallback? onCommunityLink;
final VoidCallback? onBack;
final ValueChanged<Uri> onOpenCommunity;
final VoidCallback? onScan;
final VoidCallback? onBuilderlab;
final String builderlabLabel;
final VoidCallback? onPairing;
double _textProgress(double progress, bool opening) =>
@@ -145,7 +147,7 @@ class _OnboardingConnectionView extends HookWidget {
child: const _ConnectionHeading(
title: 'Connect to a community',
subtitle:
'Scan a QR code or enter a community link to continue.',
'Sign in with email or enter a community link to continue.',
),
),
_ConnectionMotionLayer(
@@ -173,11 +175,10 @@ class _OnboardingConnectionView extends HookWidget {
active: !enteringLink,
maintainSize: true,
child: _OnboardingChoiceContainer(
title: 'Scan a QR code',
icon: BuzzIcons.qrcode,
subtitle:
'Scan a community or desktop pairing code.',
onTap: onScan,
title: builderlabLabel,
icon: BuzzIcons.mail,
subtitle: 'Use your email address to connect.',
onTap: onBuilderlab,
onIconDoubleTap: onPairing,
),
),

View File

@@ -124,7 +124,16 @@ class _PairingWelcomeView extends HookWidget {
const SizedBox(height: Grid.xs),
],
if (page == 1) ...[
if (HostedEnvironment.enabled)
if (!showConnectionOptions && !builderlabBusy)
SizedBox(
width: double.infinity,
child: FilledButton(
style: context._onboardingButtonStyle,
onPressed: isBusy ? null : onScan,
child: const Text('Scan a QR code'),
),
)
else if (HostedEnvironment.enabled)
FilledButton(
style: context._onboardingGlassButtonStyle,
onPressed: onBuilderlab,
@@ -138,12 +147,16 @@ class _PairingWelcomeView extends HookWidget {
),
if (!showConnectionOptions) ...[
const SizedBox(height: Grid.xxs),
TextButton(
style: context._onboardingGhostButtonStyle,
onPressed: isBusy
? null
: () => optionsExpanded.value = !optionsExpanded.value,
child: const Text('Other options'),
SizedBox(
width: double.infinity,
child: TextButton(
style: context._onboardingGhostButtonStyle,
onPressed: isBusy
? null
: () =>
optionsExpanded.value = !optionsExpanded.value,
child: const Text('Other options'),
),
),
],
if (showConnectionOptions) ...connectionActions(),
@@ -455,7 +468,13 @@ class _PairingWelcomeView extends HookWidget {
}
},
onOpenCommunity: onOpenCommunity,
onScan: isBusy ? null : onScan,
onBuilderlab: onBuilderlab == null
? null
: () {
optionsExpanded.value = false;
onBuilderlab!();
},
builderlabLabel: builderlabLabel,
onPairing: isBusy ? null : onAlreadyUseBuzz,
),
),

View File

@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
@@ -44,7 +45,10 @@ Future<bool> usesDynamicIslandQrScannerPortal() async {
/// Callers determine device support with
/// [usesDynamicIslandQrScannerPortal]. Standard devices reveal the camera
/// behind their existing app surface with [FallbackPairingQrScanner].
Future<String?> showDynamicIslandPairingQrScanner(BuildContext context) {
Future<String?> showDynamicIslandPairingQrScanner(
BuildContext context, {
ValueChanged<double>? onProgress,
}) {
return Navigator.of(context).push<String>(
PageRouteBuilder<String>(
opaque: false,
@@ -52,7 +56,8 @@ Future<String?> showDynamicIslandPairingQrScanner(BuildContext context) {
barrierDismissible: false,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) => const _DynamicIslandQrScannerPortal(),
pageBuilder: (_, _, _) =>
_DynamicIslandQrScannerPortal(onProgress: onProgress),
),
);
}

View File

@@ -2,11 +2,13 @@ part of '../pairing_qr_scanner.dart';
// A smooth, non-bouncy spring inspired by Apple's public motion guidance;
// these are our timings, not private Dynamic Island animation constants.
const _dynamicIslandOpenDuration = Duration(milliseconds: 440);
const _dynamicIslandCloseDuration = Duration(milliseconds: 360);
const _dynamicIslandOpenDuration = Duration(milliseconds: 300);
const _dynamicIslandCloseDuration = Duration(milliseconds: 300);
class _DynamicIslandQrScannerPortal extends HookWidget {
const _DynamicIslandQrScannerPortal();
const _DynamicIslandQrScannerPortal({this.onProgress});
final ValueChanged<double>? onProgress;
@override
Widget build(BuildContext context) {
@@ -15,7 +17,6 @@ class _DynamicIslandQrScannerPortal extends HookWidget {
final isClosing = useState(false);
final canPop = useState(false);
final hasHandledResult = useRef(false);
final cameraMounted = useRef(false);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
useEffect(() {
@@ -26,6 +27,29 @@ class _DynamicIslandQrScannerPortal extends HookWidget {
};
}, [controller]);
useEffect(() {
var active = true;
void publish() {
if (active) onProgress?.call(animation.value.clamp(0.0, 1.0));
}
void changed() {
// Reduced motion can update the controller while this route builds.
if (WidgetsBinding.instance.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
WidgetsBinding.instance.addPostFrameCallback((_) => publish());
} else {
publish();
}
}
animation.addListener(changed);
return () {
active = false;
animation.removeListener(changed);
};
}, [animation, onProgress]);
Future<void> finish(String? result) async {
if (canPop.value) return;
canPop.value = true;
@@ -74,6 +98,7 @@ class _DynamicIslandQrScannerPortal extends HookWidget {
void closePortal() {
if (isClosing.value || hasHandledResult.value) return;
hasHandledResult.value = true;
unawaited(_setDynamicIslandScannerStatusBarHidden(false));
isClosing.value = true;
}
@@ -130,9 +155,6 @@ class _DynamicIslandQrScannerPortal extends HookWidget {
final scannerOpacity = geometry.scannerOpacityAt(
progress,
);
// Once revealed, keep the camera mounted until the route
// closes. Native camera teardown can stall a moving frame.
if (scannerOpacity > 0) cameraMounted.value = true;
final introLabelOpacity = isClosing.value
? 0.0
: geometry.introLabelOpacityAt(progress);
@@ -156,14 +178,15 @@ class _DynamicIslandQrScannerPortal extends HookWidget {
child: Stack(
fit: StackFit.expand,
children: [
if (cameraMounted.value)
Opacity(
opacity: scannerOpacity,
child: _QrScannerCamera(
controller: controller,
onDetect: handleDetection,
),
// Start warming the camera on the first frame,
// and keep it mounted throughout dismissal.
Opacity(
opacity: scannerOpacity,
child: _QrScannerCamera(
controller: controller,
onDetect: handleDetection,
),
),
IgnorePointer(
child: Opacity(
opacity: promptOpacity,

View File

@@ -168,7 +168,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
? 'Connecting…'
: connected
? 'Continue with Builderlab'
: 'Sign in with builderlab',
: 'Sign in with email',
onWelcomeBack: back,
onAlreadyUseBuzz: () => existingUser.value = true,
),

View File

@@ -55,11 +55,11 @@ void main() {
);
expect(tester.getBottomRight(linkCard).dy, closeTo(876, 1));
final scanCard = find.ancestor(
of: find.text('Scan a QR code').hitTestable(),
of: find.text('Sign in with email').hitTestable(),
matching: find.byType(InkWell),
);
final subtitle = find
.text('Scan a QR code or enter a community link to continue.')
.text('Sign in with email or enter a community link to continue.')
.hitTestable();
expect(
tester.getTopLeft(scanCard).dy - tester.getBottomLeft(subtitle).dy,
@@ -114,7 +114,7 @@ void main() {
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(tester.testTextInput.isVisible, isFalse);
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
expect(find.text('Sign in with email').hitTestable(), findsOneWidget);
await tester.tap(find.text('Enter a community link').hitTestable());
await tester.pump();
// A hardware keyboard produces no software-keyboard notification.
@@ -162,7 +162,7 @@ void main() {
);
await tester.tap(find.text('Back').hitTestable());
await tester.pumpAndSettle();
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
expect(find.text('Sign in with email').hitTestable(), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
await f.dispose();
@@ -195,12 +195,9 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(
find.text('Sign in with builderlab').hitTestable(),
findsOneWidget,
);
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
expect(find.text('Enter a community link').hitTestable(), findsNothing);
expect(find.text('Scan a QR code'), findsNothing);
expect(find.text('Sign in with email').hitTestable(), findsNothing);
expect(find.text('Use pairing code'), findsNothing);
await tester.tap(find.text('Other options'));
await tester.pumpAndSettle();
@@ -208,7 +205,7 @@ void main() {
find.text('Enter a community link').hitTestable(),
findsOneWidget,
);
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
expect(find.text('Sign in with email').hitTestable(), findsOneWidget);
expect(find.text('Use pairing code').hitTestable(), findsNothing);
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
@@ -219,7 +216,9 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Other options').hitTestable(), findsOneWidget);
expect(find.text('Enter a community link').hitTestable(), findsNothing);
await tester.tap(find.text('Sign in with builderlab'));
await tester.tap(find.text('Other options'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with email').hitTestable());
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(f.browser.startedIntent, {'type': 'account'});
@@ -229,10 +228,7 @@ void main() {
);
await tester.tap(find.bySemanticsLabel('Cancel sign-in').hitTestable());
await tester.pumpAndSettle();
expect(
find.text('Sign in with builderlab').hitTestable(),
findsOneWidget,
);
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
f.expired = false;
await f.flow.signInAccount(openBrowser: false);
await tester.pumpAndSettle();

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'package:buzz/features/pairing/pairing_qr_scanner.dart';
import 'package:buzz/features/pairing/pairing_page.dart';
import '../../helpers/widget_helpers.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -164,6 +166,9 @@ void main() {
const ValueKey('dynamic-island-qr-scanner-portal'),
);
expect(tester.getRect(portal), const Rect.fromLTWH(136.5, 11, 120, 36));
expect(find.byType(MobileScanner), findsOneWidget);
await tester.pump();
expect(fakeScannerPlatform.startCalls, 1);
expect(
tester.widget<ClipRSuperellipse>(portal).borderRadius,
BorderRadius.circular(18),
@@ -173,10 +178,10 @@ void main() {
Clip.antiAlias,
);
await tester.pump(const Duration(milliseconds: 16));
await tester.pump(const Duration(milliseconds: 12));
final firstOpeningFrame = tester.getRect(portal);
expect(firstOpeningFrame.height, inExclusiveRange(36, 50));
await tester.pump(const Duration(milliseconds: 214));
await tester.pump(const Duration(milliseconds: 218));
final openingRadius =
(tester.widget<ClipRSuperellipse>(portal).borderRadius
as BorderRadius)
@@ -242,6 +247,69 @@ void main() {
},
);
testWidgets(
'welcome content follows the scanner spring and returns on close',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async {
return call.method == 'usesDynamicIslandQrScannerPortal'
? true
: null;
});
await tester.binding.setSurfaceSize(const Size(393, 852));
addTearDown(() => tester.binding.setSurfaceSize(null));
for (final reduced in [false, true]) {
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: reduced,
child: const PairingPage(
connectionLanding: true,
showConnectionOptions: false,
),
),
);
await tester.pumpAndSettle();
final opacity = find.byKey(const Key('scanner-welcome-opacity'));
final scale = find.byKey(const Key('scanner-welcome-scale'));
expect(tester.widget<Opacity>(opacity).opacity, 1);
await tester.tap(find.text('Scan a QR code').hitTestable());
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
if (!reduced) {
final portal = find.byKey(
const ValueKey('dynamic-island-qr-scanner-portal'),
);
final progress = (tester.getSize(portal).height - 36) / (363 - 36);
expect(
tester.widget<Opacity>(opacity).opacity,
closeTo(1 - progress, 0.001),
);
expect(
tester.widget<Transform>(scale).transform.storage[0],
closeTo(1 - 0.04 * progress, 0.001),
);
}
await tester.pumpAndSettle();
expect(tester.widget<Opacity>(opacity).opacity, 0);
expect(
tester.widget<Transform>(scale).transform.storage[0],
closeTo(reduced ? 1 : 0.96, 0.001),
);
expect(find.text('Other options').hitTestable(), findsNothing);
await tester.tapAt(const Offset(196.5, 700));
await tester.pumpAndSettle();
expect(tester.widget<Opacity>(opacity).opacity, 1);
expect(tester.widget<Transform>(scale).transform.storage[0], 1);
expect(find.text('Other options').hitTestable(), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
}
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets(
'an early dismissal keeps its frame and settles back into the island',
(tester) async {
@@ -263,19 +331,28 @@ void main() {
);
await tester.tap(find.text('Open scanner'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
await tester.pump(const Duration(milliseconds: 72));
final portal = find.byKey(
const ValueKey('dynamic-island-qr-scanner-portal'),
);
final beforeLastOpeningFrame = tester.getRect(portal);
await tester.pump(const Duration(milliseconds: 8));
final beforeDismissal = tester.getRect(portal);
await tester.tapAt(const Offset(196.5, 700));
await tester.pump();
expect(tester.getRect(portal), beforeDismissal);
expect(
tester.getRect(portal),
rectMoreOrLessEquals(beforeDismissal, epsilon: 0.00001),
);
// Preserve the outgoing velocity briefly instead of abruptly flipping it.
await tester.pump(const Duration(milliseconds: 8));
final afterDismissal = tester.getRect(portal);
expect(afterDismissal.height, greaterThan(beforeDismissal.height));
expect(afterDismissal.height - beforeDismissal.height, lessThan(12));
// Reversing decelerates the outgoing motion over an equal frame interval.
expect(
afterDismissal.height - beforeDismissal.height,
lessThan(beforeDismissal.height - beforeLastOpeningFrame.height),
);
await tester.tapAt(const Offset(196.5, 700));
await tester.pumpAndSettle();
expect(closeCount, 1);
@@ -345,6 +422,7 @@ class _FakeMobileScannerPlatform extends MobileScannerPlatform {
final _barcodes = StreamController<BarcodeCapture?>.broadcast();
var _isDisposed = false;
var stopCalls = 0;
var startCalls = 0;
@override
Stream<BarcodeCapture?> get barcodesStream => _barcodes.stream;
@@ -358,6 +436,7 @@ class _FakeMobileScannerPlatform extends MobileScannerPlatform {
@override
Future<MobileScannerViewAttributes> start(StartOptions startOptions) async {
startCalls++;
return const MobileScannerViewAttributes(
cameraDirection: CameraFacing.back,
currentTorchMode: TorchState.unavailable,

View File

@@ -101,18 +101,12 @@ void main() {
);
await tester.pump();
expect(find.bySemanticsLabel('Buzz'), findsOneWidget);
expect(
find.text('Sign in with builderlab'),
HostedEnvironment.enabled ? findsOneWidget : findsNothing,
);
expect(find.text('Sign in with email').hitTestable(), findsNothing);
expect(
find.text('Other options'),
HostedEnvironment.enabled ? findsOneWidget : findsNothing,
);
expect(
find.text('Scan a QR code'),
HostedEnvironment.enabled ? findsNothing : findsOneWidget,
);
expect(find.text('Scan a QR code'), findsOneWidget);
});
}