mirror of
https://github.com/block/buzz.git
synced 2026-11-13 04:41:12 +00:00
Merge remote-tracking branch 'origin/main' into kennylopez-iphone-duo-devicehub
Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> # Conflicts: # mobile/lib/features/pairing/pairing_page.dart # mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart # mobile/test/features/pairing/pairing_qr_scanner_test.dart
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.23,
|
||||
delay: 0,
|
||||
options: [.beginFromCurrentState, .curveEaseInOut]
|
||||
) {
|
||||
self.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import 'package:flutter/cupertino.dart'
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/shared/theme/buzz_icons.dart';
|
||||
@@ -35,6 +37,7 @@ part 'pairing_page/onboarding_background.dart';
|
||||
part 'pairing_page/onboarding_colors.dart';
|
||||
part 'pairing_page/onboarding_glass_button.dart';
|
||||
part 'pairing_page/pairing_welcome_view.dart';
|
||||
part 'pairing_page/two_finger_double_tap.dart';
|
||||
part 'pairing_page/community_link_form.dart';
|
||||
part 'pairing_page/onboarding_connection_view.dart';
|
||||
part 'pairing_page/sas_verification_view.dart';
|
||||
@@ -61,6 +64,7 @@ class PairingPage extends HookConsumerWidget {
|
||||
final Widget? setupContent;
|
||||
final String builderlabLabel;
|
||||
final bool builderlabBusy;
|
||||
final bool signInPending;
|
||||
|
||||
const PairingPage({
|
||||
super.key,
|
||||
@@ -81,6 +85,7 @@ class PairingPage extends HookConsumerWidget {
|
||||
this.setupContent,
|
||||
this.builderlabLabel = 'Connect to Builderlab',
|
||||
this.builderlabBusy = false,
|
||||
this.signInPending = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -92,6 +97,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 +143,7 @@ class PairingPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
Future<void> openScanner() async {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
final usesDynamicIslandPortal =
|
||||
duoQrScannerCameraFrame(context) != null ||
|
||||
await usesDynamicIslandQrScannerPortal();
|
||||
@@ -149,7 +156,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);
|
||||
}
|
||||
|
||||
@@ -281,43 +298,67 @@ 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,
|
||||
signInPending: signInPending,
|
||||
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,6 @@ class _OnboardingConnectionView extends HookWidget {
|
||||
required this.onCommunityLink,
|
||||
required this.onBack,
|
||||
required this.onOpenCommunity,
|
||||
required this.onScan,
|
||||
required this.onPairing,
|
||||
});
|
||||
|
||||
@@ -15,7 +14,6 @@ class _OnboardingConnectionView extends HookWidget {
|
||||
final VoidCallback? onCommunityLink;
|
||||
final VoidCallback? onBack;
|
||||
final ValueChanged<Uri> onOpenCommunity;
|
||||
final VoidCallback? onScan;
|
||||
final VoidCallback? onPairing;
|
||||
|
||||
double _textProgress(double progress, bool opening) =>
|
||||
@@ -145,7 +143,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.',
|
||||
'Enter a community link or use a pairing code to continue.',
|
||||
),
|
||||
),
|
||||
_ConnectionMotionLayer(
|
||||
@@ -173,12 +171,11 @@ class _OnboardingConnectionView extends HookWidget {
|
||||
active: !enteringLink,
|
||||
maintainSize: true,
|
||||
child: _OnboardingChoiceContainer(
|
||||
title: 'Scan a QR code',
|
||||
title: 'Use a pairing code',
|
||||
icon: BuzzIcons.qrcode,
|
||||
subtitle:
|
||||
'Scan a community or desktop pairing code.',
|
||||
onTap: onScan,
|
||||
onIconDoubleTap: onPairing,
|
||||
'Paste the pairing code from your desktop app.',
|
||||
onTap: onPairing,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -408,7 +405,6 @@ class _OnboardingChoiceContainer extends StatelessWidget {
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
this.onTap,
|
||||
this.onIconDoubleTap,
|
||||
this.transparent = false,
|
||||
});
|
||||
|
||||
@@ -417,7 +413,6 @@ class _OnboardingChoiceContainer extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool transparent;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onIconDoubleTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
@@ -445,20 +440,13 @@ class _OnboardingChoiceContainer extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ExcludeSemantics(
|
||||
child: GestureDetector(
|
||||
key: onIconDoubleTap == null
|
||||
? null
|
||||
: const Key('developer-pairing-trigger'),
|
||||
excludeFromSemantics: true,
|
||||
onDoubleTap: onIconDoubleTap,
|
||||
child: SizedBox(
|
||||
width: Grid.sm,
|
||||
height: Grid.sm,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: Grid.sm,
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: Grid.sm,
|
||||
height: Grid.sm,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: Grid.sm,
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -24,6 +24,7 @@ class _PairingWelcomeView extends HookWidget {
|
||||
final Widget? setupContent;
|
||||
final String builderlabLabel;
|
||||
final bool builderlabBusy;
|
||||
final bool signInPending;
|
||||
final ValueChanged<Uri> onOpenCommunity;
|
||||
|
||||
const _PairingWelcomeView({
|
||||
@@ -51,6 +52,7 @@ class _PairingWelcomeView extends HookWidget {
|
||||
this.setupContent,
|
||||
this.builderlabLabel = 'Connect to Builderlab',
|
||||
this.builderlabBusy = false,
|
||||
this.signInPending = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -124,7 +126,16 @@ class _PairingWelcomeView extends HookWidget {
|
||||
const SizedBox(height: Grid.xs),
|
||||
],
|
||||
if (page == 1) ...[
|
||||
if (HostedEnvironment.enabled)
|
||||
if (!showConnectionOptions && !signInPending)
|
||||
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,
|
||||
@@ -136,14 +147,17 @@ class _PairingWelcomeView extends HookWidget {
|
||||
)
|
||||
: Text(builderlabLabel),
|
||||
),
|
||||
if (!showConnectionOptions) ...[
|
||||
if (!showConnectionOptions &&
|
||||
!signInPending &&
|
||||
HostedEnvironment.enabled) ...[
|
||||
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 : onBuilderlab,
|
||||
child: const Text('or sign in with email'),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (showConnectionOptions) ...connectionActions(),
|
||||
@@ -298,6 +312,17 @@ class _PairingWelcomeView extends HookWidget {
|
||||
],
|
||||
);
|
||||
|
||||
void revealOptions() {
|
||||
optionsExpanded.value = true;
|
||||
}
|
||||
|
||||
final canRevealOptions =
|
||||
connectionLanding &&
|
||||
!showConnectionOptions &&
|
||||
!existingUser &&
|
||||
setupContent == null &&
|
||||
!signInPending &&
|
||||
!isBusy;
|
||||
final welcome = Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.gutter,
|
||||
@@ -313,31 +338,46 @@ class _PairingWelcomeView extends HookWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 777 / 326,
|
||||
child: OnboardingWordmark(
|
||||
key: const Key('pairing-buzz-wordmark'),
|
||||
color: context._onboardingIsDark
|
||||
? context._onboardingInk
|
||||
: null,
|
||||
child: _TwoFingerDoubleTapSurface(
|
||||
onDoubleTap: canRevealOptions ? revealOptions : null,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 777 / 326,
|
||||
child: Semantics(
|
||||
// Hidden developer entry intentionally has no
|
||||
// keyboard shortcut. Keep its assistive action
|
||||
// on the existing labelled wordmark node.
|
||||
customSemanticsActions: canRevealOptions
|
||||
? {
|
||||
const CustomSemanticsAction(
|
||||
label: 'More connection options',
|
||||
): revealOptions,
|
||||
}
|
||||
: null,
|
||||
child: OnboardingWordmark(
|
||||
key: const Key('pairing-buzz-wordmark'),
|
||||
color: context._onboardingIsDark
|
||||
? context._onboardingInk
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'Your people, your agents, your projects —\nall in one place.',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context._onboardingInk,
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
'Your people, your agents, your projects —\nall in one place.',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context._onboardingInk,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -455,8 +495,12 @@ class _PairingWelcomeView extends HookWidget {
|
||||
}
|
||||
},
|
||||
onOpenCommunity: onOpenCommunity,
|
||||
onScan: isBusy ? null : onScan,
|
||||
onPairing: isBusy ? null : onAlreadyUseBuzz,
|
||||
onPairing: isBusy
|
||||
? null
|
||||
: () {
|
||||
optionsExpanded.value = false;
|
||||
onAlreadyUseBuzz?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
part of '../pairing_page.dart';
|
||||
|
||||
class _TwoFingerDoubleTapSurface extends HookWidget {
|
||||
const _TwoFingerDoubleTapSurface({required this.child, this.onDoubleTap});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onDoubleTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final taps = useMemoized(_TwoFingerTapTracker.new, [onDoubleTap != null]);
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: onDoubleTap == null ? null : taps.down,
|
||||
onPointerMove: onDoubleTap == null ? null : taps.move,
|
||||
onPointerCancel: onDoubleTap == null ? null : (_) => taps.cancel(),
|
||||
onPointerUp: onDoubleTap == null
|
||||
? null
|
||||
: (event) => taps.up(event, onDoubleTap!),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Observe two short, stationary two-finger chords without competing with taps
|
||||
// or scrolling in Flutter's gesture arena.
|
||||
class _TwoFingerTapTracker {
|
||||
final _starts = <int, Offset>{};
|
||||
final _down = <int>{};
|
||||
Duration _started = Duration.zero;
|
||||
Duration? _previousEnd;
|
||||
Offset? _previousCenter;
|
||||
bool _valid = true;
|
||||
|
||||
void down(PointerDownEvent event) {
|
||||
if (_down.isEmpty) {
|
||||
_starts.clear();
|
||||
_started = event.timeStamp;
|
||||
_valid = true;
|
||||
}
|
||||
_starts[event.pointer] = event.position;
|
||||
_down.add(event.pointer);
|
||||
if (_starts.length > 2) _valid = false;
|
||||
}
|
||||
|
||||
void move(PointerMoveEvent event) {
|
||||
final start = _starts[event.pointer];
|
||||
if (start != null && (event.position - start).distance > kTouchSlop) {
|
||||
_valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_down.clear();
|
||||
_starts.clear();
|
||||
_previousEnd = null;
|
||||
_previousCenter = null;
|
||||
_valid = false;
|
||||
}
|
||||
|
||||
void up(PointerUpEvent event, VoidCallback onDoubleTap) {
|
||||
if (!_down.remove(event.pointer) || _down.isNotEmpty) return;
|
||||
if (!_valid ||
|
||||
_starts.length != 2 ||
|
||||
event.timeStamp - _started > kDoubleTapTimeout) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
final center = (_starts.values.first + _starts.values.last) / 2;
|
||||
final gap = _previousEnd == null ? null : _started - _previousEnd!;
|
||||
final isDouble =
|
||||
gap != null &&
|
||||
gap >= kDoubleTapMinTime &&
|
||||
gap <= kDoubleTapTimeout &&
|
||||
(center - _previousCenter!).distance <= kDoubleTapSlop;
|
||||
_previousEnd = event.timeStamp;
|
||||
_previousCenter = center;
|
||||
if (isDouble) {
|
||||
cancel();
|
||||
onDoubleTap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,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';
|
||||
@@ -64,7 +65,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,
|
||||
@@ -72,7 +76,8 @@ Future<String?> showDynamicIslandPairingQrScanner(BuildContext context) {
|
||||
barrierDismissible: false,
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
pageBuilder: (_, _, _) => const _DynamicIslandQrScannerPortal(),
|
||||
pageBuilder: (_, _, _) =>
|
||||
_DynamicIslandQrScannerPortal(onProgress: onProgress),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -142,7 +147,6 @@ class DynamicIslandQrScannerGeometry {
|
||||
static const _edgeMargin = 15.0;
|
||||
static const _expandedCornerRadius = 40.0;
|
||||
static const _scannerFadeStart = 0.18;
|
||||
static const _introLabelFadeEnd = 0.42;
|
||||
|
||||
/// The physical-island-aligned top edge used throughout the morph.
|
||||
double get top =>
|
||||
@@ -212,9 +216,4 @@ class DynamicIslandQrScannerGeometry {
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the opacity of the compact prompt inside the island pill.
|
||||
double introLabelOpacityAt(double progress) {
|
||||
return (1 - (progress / _introLabelFadeEnd)).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 230);
|
||||
const _dynamicIslandCloseDuration = Duration(milliseconds: 230);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -131,17 +156,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 || geometry.cameraFrame != null
|
||||
? 0.0
|
||||
: geometry.introLabelOpacityAt(progress);
|
||||
final promptOpacity = math.max(
|
||||
introLabelOpacity,
|
||||
scannerOpacity,
|
||||
);
|
||||
|
||||
return Positioned.fromRect(
|
||||
rect: frame,
|
||||
@@ -158,17 +172,18 @@ 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,
|
||||
opacity: scannerOpacity,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Scan a QR code',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
part of '../pairing_qr_scanner.dart';
|
||||
|
||||
class _QrScannerCamera extends StatelessWidget {
|
||||
class _QrScannerCamera extends HookWidget {
|
||||
const _QrScannerCamera({required this.controller, required this.onDetect});
|
||||
|
||||
final MobileScannerController controller;
|
||||
@@ -8,46 +8,79 @@ class _QrScannerCamera extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MobileScanner(
|
||||
controller: controller,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error) {
|
||||
final message = switch (error.errorCode) {
|
||||
MobileScannerErrorCode.permissionDenied =>
|
||||
'Camera permission is required to scan QR codes.\n\n'
|
||||
'Please grant camera access in your device settings.',
|
||||
_ =>
|
||||
'Could not start camera: '
|
||||
"${error.errorDetails?.message ?? 'unknown error'}",
|
||||
};
|
||||
return ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
BuzzIcons.cameraOff,
|
||||
size: 48,
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
final cameraState = useState(controller.value);
|
||||
useEffect(() {
|
||||
var active = true;
|
||||
void publish() {
|
||||
if (active) cameraState.value = controller.value;
|
||||
}
|
||||
|
||||
void changed() {
|
||||
// The scanner can publish initialization state while its child builds.
|
||||
if (WidgetsBinding.instance.schedulerPhase ==
|
||||
SchedulerPhase.persistentCallbacks) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => publish());
|
||||
} else {
|
||||
publish();
|
||||
}
|
||||
}
|
||||
|
||||
controller.addListener(changed);
|
||||
return () {
|
||||
active = false;
|
||||
controller.removeListener(changed);
|
||||
};
|
||||
}, [controller]);
|
||||
final state = cameraState.value;
|
||||
final ready = state.isInitialized && state.isRunning;
|
||||
return AnimatedOpacity(
|
||||
key: const ValueKey('qr-camera-ready-fade'),
|
||||
opacity: ready || state.error != null ? 1 : 0,
|
||||
duration: MediaQuery.disableAnimationsOf(context) || state.error != null
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 230),
|
||||
curve: Curves.easeOut,
|
||||
child: MobileScanner(
|
||||
controller: controller,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error) {
|
||||
final message = switch (error.errorCode) {
|
||||
MobileScannerErrorCode.permissionDenied =>
|
||||
'Camera permission is required to scan QR codes.\n\n'
|
||||
'Please grant camera access in your device settings.',
|
||||
_ =>
|
||||
'Could not start camera: '
|
||||
"${error.errorDetails?.message ?? 'unknown error'}",
|
||||
};
|
||||
return ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.sm),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
BuzzIcons.cameraOff,
|
||||
size: 48,
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: Grid.xs),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onDetect: onDetect,
|
||||
);
|
||||
},
|
||||
onDetect: onDetect,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
|
||||
: () => unawaited(flow.signInAccount()),
|
||||
accountStatus: status,
|
||||
builderlabBusy: busy,
|
||||
signInPending: signingIn || busy,
|
||||
setupContent: flow != null && inlineSetup
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -168,7 +169,7 @@ class OnboardingAppEntry extends HookConsumerWidget {
|
||||
? 'Connecting…'
|
||||
: connected
|
||||
? 'Continue with Builderlab'
|
||||
: 'Sign in with builderlab',
|
||||
: 'Sign in with email',
|
||||
onWelcomeBack: back,
|
||||
onAlreadyUseBuzz: () => existingUser.value = true,
|
||||
),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:buzz/shared/hosted/hosted_community_creation.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
||||
import 'package:buzz/features/onboarding/hosted_connect_page.dart';
|
||||
import 'package:buzz/hosted_app_gate.dart';
|
||||
@@ -9,6 +12,7 @@ import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_community_connection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -45,7 +49,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Other options'));
|
||||
await revealOptionsWithSemantics(tester);
|
||||
await tester.pumpAndSettle();
|
||||
tester.view.physicalSize = const Size(420, 900);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -55,11 +59,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('Use a pairing code').hitTestable(),
|
||||
matching: find.byType(InkWell),
|
||||
);
|
||||
final subtitle = find
|
||||
.text('Scan a QR code or enter a community link to continue.')
|
||||
.text('Enter a community link or use a pairing code to continue.')
|
||||
.hitTestable();
|
||||
expect(
|
||||
tester.getTopLeft(scanCard).dy - tester.getBottomLeft(subtitle).dy,
|
||||
@@ -114,7 +118,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('Use a pairing code').hitTestable(), findsOneWidget);
|
||||
await tester.tap(find.text('Enter a community link').hitTestable());
|
||||
await tester.pump();
|
||||
// A hardware keyboard produces no software-keyboard notification.
|
||||
@@ -147,12 +151,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.testTextInput.isVisible, isFalse);
|
||||
expect(find.text('Use pairing code'), findsNothing);
|
||||
final developerTrigger = find
|
||||
.byKey(const Key('developer-pairing-trigger'))
|
||||
.hitTestable();
|
||||
await tester.tap(developerTrigger);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.tap(developerTrigger);
|
||||
await tester.tap(find.text('Use a pairing code').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find
|
||||
@@ -162,7 +161,10 @@ void main() {
|
||||
);
|
||||
await tester.tap(find.text('Back').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
|
||||
expect(
|
||||
find.text('or sign in with email').hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
await f.dispose();
|
||||
@@ -195,31 +197,31 @@ 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 revealOptions(tester);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('Enter a community link').hitTestable(),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
|
||||
expect(find.text('Use a pairing code').hitTestable(), findsOneWidget);
|
||||
expect(find.text('Use pairing code').hitTestable(), findsNothing);
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Enter a community link').hitTestable(), findsNothing);
|
||||
await tester.tap(find.text('Other options'));
|
||||
await revealOptions(tester);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Other options').hitTestable(), findsOneWidget);
|
||||
expect(
|
||||
find.text('or sign in with email').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('or sign in with email').hitTestable());
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(f.browser.startedIntent, {'type': 'account'});
|
||||
@@ -229,10 +231,18 @@ 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);
|
||||
// Target sign-in remains pending when an Android Custom Tab closes.
|
||||
await f.flow.start(HostedCreateCommunity('new-community'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(f.flow.accountOnly, isFalse);
|
||||
expect(f.flow.stage, HostedFlowStage.signIn);
|
||||
expect(find.text('Cancel sign-in').hitTestable(), findsOneWidget);
|
||||
expect(find.text('Scan a QR code').hitTestable(), findsNothing);
|
||||
await tester.tap(find.text('Cancel sign-in').hitTestable());
|
||||
await tester.pumpAndSettle();
|
||||
expect(f.flow.stage, HostedFlowStage.idle);
|
||||
expect(find.text('Scan a QR code').hitTestable(), findsOneWidget);
|
||||
f.expired = false;
|
||||
await f.flow.signInAccount(openBrowser: false);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -289,3 +299,57 @@ void main() {
|
||||
skip: HostedEnvironment.authDomains.isEmpty,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> revealOptions(WidgetTester tester) async {
|
||||
// Ordinary double taps must not expose the hidden connection options.
|
||||
await tester.tapAt(const Offset(24, 120));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await tester.tapAt(const Offset(24, 120));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Use a pairing code').hitTestable(), findsNothing);
|
||||
|
||||
for (var tap = 0; tap < 2; tap++) {
|
||||
final first = await tester.createGesture(pointer: 11);
|
||||
final second = await tester.createGesture(pointer: 12);
|
||||
await first.down(
|
||||
const Offset(24, 120),
|
||||
timeStamp: Duration(milliseconds: tap * 70),
|
||||
);
|
||||
await second.down(
|
||||
const Offset(70, 120),
|
||||
timeStamp: Duration(milliseconds: tap * 70),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 20));
|
||||
await first.up(timeStamp: Duration(milliseconds: tap * 70 + 20));
|
||||
await second.up(timeStamp: Duration(milliseconds: tap * 70 + 20));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> revealOptionsWithSemantics(WidgetTester tester) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
try {
|
||||
await tester.pump();
|
||||
final wordmark = tester.getSemantics(find.bySemanticsLabel('Buzz'));
|
||||
final data = wordmark.getSemanticsData();
|
||||
final actionIds = data.customSemanticsActionIds!;
|
||||
expect(actionIds, hasLength(1));
|
||||
expect(
|
||||
CustomSemanticsAction.getAction(actionIds.single)!.label,
|
||||
'More connection options',
|
||||
);
|
||||
// The action belongs to the wordmark, never an unlabelled page-sized stop.
|
||||
expect(data.label, 'Buzz');
|
||||
expect(wordmark.rect.height, lessThan(200));
|
||||
wordmark.owner!.performAction(
|
||||
wordmark.id,
|
||||
ui.SemanticsAction.customAction,
|
||||
actionIds.single,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Enter a community link').hitTestable(), findsOneWidget);
|
||||
expect(find.text('Use a pairing code').hitTestable(), findsOneWidget);
|
||||
} finally {
|
||||
handle.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
|
||||
import 'package:buzz/features/pairing/pairing_qr_scanner.dart';
|
||||
import 'package:duo_kit/testing.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';
|
||||
@@ -212,6 +214,8 @@ void main() {
|
||||
await tester.binding.setSurfaceSize(const Size(393, 852));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final cameraStart = Completer<void>();
|
||||
fakeScannerPlatform.startGate = cameraStart.future;
|
||||
var scannerClosed = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@@ -240,6 +244,22 @@ void main() {
|
||||
const ValueKey('dynamic-island-qr-scanner-portal'),
|
||||
);
|
||||
expect(tester.getRect(portal), const Rect.fromLTWH(136.5, 11, 120, 36));
|
||||
final prompt = find
|
||||
.ancestor(
|
||||
of: find.text('Scan a QR code'),
|
||||
matching: find.byType(Opacity),
|
||||
)
|
||||
.first;
|
||||
final camera = find
|
||||
.ancestor(
|
||||
of: find.byType(MobileScanner),
|
||||
matching: find.byType(Opacity),
|
||||
)
|
||||
.first;
|
||||
expect(tester.widget<Opacity>(prompt).opacity, 0);
|
||||
expect(find.byType(MobileScanner), findsOneWidget);
|
||||
await tester.pump();
|
||||
expect(fakeScannerPlatform.startCalls, 1);
|
||||
expect(
|
||||
tester.widget<ClipRSuperellipse>(portal).borderRadius,
|
||||
BorderRadius.circular(18),
|
||||
@@ -249,10 +269,19 @@ 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));
|
||||
expect(firstOpeningFrame.height, inExclusiveRange(36, 363));
|
||||
await tester.pump(const Duration(milliseconds: 218));
|
||||
expect(
|
||||
tester.widget<Opacity>(prompt).opacity,
|
||||
tester.widget<Opacity>(camera).opacity,
|
||||
);
|
||||
expect(tester.widget<Opacity>(prompt).opacity, inExclusiveRange(0, 1));
|
||||
expect(
|
||||
tester.getRect(portal).height,
|
||||
greaterThan(firstOpeningFrame.height),
|
||||
);
|
||||
final openingRadius =
|
||||
(tester.widget<ClipRSuperellipse>(portal).borderRadius
|
||||
as BorderRadius)
|
||||
@@ -261,6 +290,21 @@ void main() {
|
||||
expect(openingRadius, inExclusiveRange(18, 40));
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
final readyFade = find.byKey(const ValueKey('qr-camera-ready-fade'));
|
||||
final previewFade = find
|
||||
.descendant(of: readyFade, matching: find.byType(FadeTransition))
|
||||
.first;
|
||||
expect(tester.widget<FadeTransition>(previewFade).opacity.value, 0);
|
||||
cameraStart.complete();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
expect(
|
||||
tester.widget<FadeTransition>(previewFade).opacity.value,
|
||||
inExclusiveRange(0, 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<FadeTransition>(previewFade).opacity.value, 1);
|
||||
expect(
|
||||
tester.widget<ClipRSuperellipse>(portal).borderRadius,
|
||||
BorderRadius.circular(40),
|
||||
@@ -318,6 +362,90 @@ 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();
|
||||
// Opening must begin without waiting for the welcome double-tap timeout.
|
||||
expect(
|
||||
find.byKey(const ValueKey('dynamic-island-qr-scanner-portal')),
|
||||
findsOneWidget,
|
||||
);
|
||||
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
|
||||
.ancestor(
|
||||
of: find.text('Scan a QR code'),
|
||||
matching: find.byType(FilledButton),
|
||||
)
|
||||
.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
|
||||
.ancestor(
|
||||
of: find.text('Scan a QR code'),
|
||||
matching: find.byType(FilledButton),
|
||||
)
|
||||
.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 {
|
||||
@@ -339,19 +467,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);
|
||||
@@ -421,6 +558,8 @@ class _FakeMobileScannerPlatform extends MobileScannerPlatform {
|
||||
final _barcodes = StreamController<BarcodeCapture?>.broadcast();
|
||||
var _isDisposed = false;
|
||||
var stopCalls = 0;
|
||||
var startCalls = 0;
|
||||
Future<void>? startGate;
|
||||
|
||||
@override
|
||||
Stream<BarcodeCapture?> get barcodesStream => _barcodes.stream;
|
||||
@@ -434,6 +573,8 @@ class _FakeMobileScannerPlatform extends MobileScannerPlatform {
|
||||
|
||||
@override
|
||||
Future<MobileScannerViewAttributes> start(StartOptions startOptions) async {
|
||||
startCalls++;
|
||||
await startGate;
|
||||
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 email').hitTestable(), findsNothing);
|
||||
expect(
|
||||
find.text('Sign in with builderlab'),
|
||||
find.text('or sign in with email'),
|
||||
HostedEnvironment.enabled ? findsOneWidget : 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