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:
kenny lopez
2026-10-27 17:26:13 +00:00
14 changed files with 638 additions and 204 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.23,
delay: 0,
options: [.beginFromCurrentState, .curveEaseInOut]
) {
self.setNeedsStatusBarAppearanceUpdate()
}
}
}

View File

@@ -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));
}
},
),
),
),
),

View File

@@ -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,
),
),
),

View File

@@ -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();
},
),
),
),

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

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: 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',

View File

@@ -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,
),
);
}
}

View File

@@ -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,
),

View File

@@ -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();
}
}

View File

@@ -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,

View File

@@ -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);
});
}