mirror of
https://github.com/block/buzz.git
synced 2026-11-13 04:41:12 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user