Add adaptive community QR scanner (#2739)

## Summary
- Expand the pairing scanner from the Dynamic Island on supported
iPhones
- Reveal the camera behind the pairing UI on Android and standard
iPhones
- Preserve tap-to-dismiss and reduced-motion behavior

## Testing
- `just mobile-check`
- `just mobile-test`
- iOS `RunnerTests`
This commit is contained in:
klopez4212
2026-07-24 16:22:43 -07:00
committed by GitHub
parent e8105d1446
commit bb445d3cf1
8 changed files with 1081 additions and 85 deletions
+58
View File
@@ -6,6 +6,7 @@ import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var mediaUploadChannel: FlutterMethodChannel?
private var qrScannerChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
@@ -24,6 +25,63 @@ import UserNotifications
mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in
self?.handleMediaUploadMethodCall(call, result: result)
}
qrScannerChannel = FlutterMethodChannel(
name: "buzz/qr_scanner",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
qrScannerChannel?.setMethodCallHandler { call, result in
Self.handleQrScannerMethodCall(call, result: result)
}
}
private static func handleQrScannerMethodCall(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
switch call.method {
case "usesDynamicIslandQrScannerPortal":
result(
UIDevice.current.userInterfaceIdiom == .phone
&& usesDynamicIslandQrScannerPortal(
safeAreaTopInset: activeWindowSafeAreaTopInset()
)
)
case "setDynamicIslandScannerStatusBarHidden":
guard let hidden = call.arguments as? Bool else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected a Bool status-bar visibility value.",
details: nil
)
)
return
}
UIApplication.shared.setStatusBarHidden(hidden, with: .fade)
result(nil)
case "performDynamicIslandQrScanSuccessHaptic":
let generator = UINotificationFeedbackGenerator()
generator.prepare()
generator.notificationOccurred(.success)
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
static func usesDynamicIslandQrScannerPortal(
safeAreaTopInset: CGFloat
) -> Bool {
safeAreaTopInset > 50
}
private static func activeWindowSafeAreaTopInset() -> CGFloat {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?
.safeAreaInsets.top ?? 0
}
private func handleMediaUploadMethodCall(
+22
View File
@@ -6,6 +6,28 @@ import XCTest
class RunnerTests: XCTestCase {
func testDynamicIslandQrScannerRecognizesTallSafeAreas() {
for safeAreaTopInset in [51, 59, 62] {
XCTAssertTrue(
AppDelegate.usesDynamicIslandQrScannerPortal(
safeAreaTopInset: CGFloat(safeAreaTopInset)
),
"\(safeAreaTopInset)"
)
}
}
func testDynamicIslandQrScannerRejectsStandardSafeAreas() {
for safeAreaTopInset in [0, 44, 47, 50] {
XCTAssertFalse(
AppDelegate.usesDynamicIslandQrScannerPortal(
safeAreaTopInset: CGFloat(safeAreaTopInset)
),
"\(safeAreaTopInset)"
)
}
}
func testClipboardImageDataPrefersOriginalPngBytes() throws {
let pasteboard = try XCTUnwrap(
UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true)
+37 -85
View File
@@ -1,11 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../shared/theme/theme.dart';
import 'pairing_provider.dart';
import 'pairing_qr_scanner.dart';
class PairingPage extends HookConsumerWidget {
/// When true, the pairing page is being used to add a new community
@@ -18,6 +20,7 @@ class PairingPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final pairingState = ref.watch(pairingProvider);
final codeController = useTextEditingController();
final fallbackScannerVisible = useState(false);
final isBusy =
pairingState.status == PairingStatus.connecting ||
pairingState.status == PairingStatus.transferring ||
@@ -33,7 +36,28 @@ class PairingPage extends HookConsumerWidget {
});
}
return PopScope(
Future<void> handleScannerResult(String? code) async {
if (code != null && context.mounted) {
await ref.read(pairingProvider.notifier).pair(code);
}
}
Future<void> openScanner() async {
final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal();
if (!context.mounted) {
return;
}
if (!usesDynamicIslandPortal) {
fallbackScannerVisible.value = true;
return;
}
final code = await showDynamicIslandPairingQrScanner(context);
await handleScannerResult(code);
}
final appSurface = PopScope(
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
ref.read(pairingProvider.notifier).reset();
@@ -84,9 +108,7 @@ class PairingPage extends HookConsumerWidget {
// Scan QR button
FilledButton.icon(
onPressed: isBusy
? null
: () => _openScanner(context, ref),
onPressed: isBusy ? null : openScanner,
icon: const Icon(LucideIcons.scanLine),
label: const Text('Scan QR Code'),
),
@@ -198,18 +220,17 @@ class PairingPage extends HookConsumerWidget {
),
),
);
}
void _openScanner(BuildContext context, WidgetRef ref) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _ScannerPage(
onScanned: (code) {
Navigator.of(context).pop();
ref.read(pairingProvider.notifier).pair(code);
},
),
),
if (!fallbackScannerVisible.value) {
return appSurface;
}
return FallbackPairingQrScanner(
appSurface: appSurface,
onClosed: (code) {
fallbackScannerVisible.value = false;
unawaited(handleScannerResult(code));
},
);
}
}
@@ -336,72 +357,3 @@ class _SasVerificationView extends StatelessWidget {
);
}
}
class _ScannerPage extends HookWidget {
final void Function(String code) onScanned;
const _ScannerPage({required this.onScanned});
@override
Widget build(BuildContext context) {
final handled = useState(false);
final controller = useMemoized(() => MobileScannerController());
useEffect(() => controller.dispose, const []);
return Scaffold(
appBar: AppBar(
title: const Text('Scan QR Code'),
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.of(context).pop(),
),
),
body: MobileScanner(
controller: controller,
errorBuilder: (context, error) {
final message = switch (error.errorCode) {
MobileScannerErrorCode.permissionDenied =>
'Camera permission is required to scan QR codes.\n\nPlease grant camera access in your device settings.',
_ =>
'Could not start camera: ${error.errorDetails?.message ?? 'unknown error'}',
};
return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.cameraOff,
size: 48,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.xs),
Text(
message,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
);
},
onDetect: (capture) {
if (handled.value) return;
final barcodes = capture.barcodes;
if (barcodes.isNotEmpty) {
final value = barcodes.first.rawValue;
if (value != null && value.isNotEmpty) {
handled.value = true;
onScanned(value);
}
}
},
),
);
}
}
@@ -0,0 +1,175 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' show lerpDouble;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../shared/theme/theme.dart';
part 'pairing_qr_scanner/dynamic_island_portal.dart';
part 'pairing_qr_scanner/fallback_scanner.dart';
part 'pairing_qr_scanner/scanner_camera.dart';
const _qrScannerPlatformChannel = MethodChannel('buzz/qr_scanner');
/// Returns whether the current iPhone should use the Dynamic Island scanner.
///
/// Android, iPad, and iPhones without a Dynamic Island always use the standard
/// full-screen scanner.
Future<bool> usesDynamicIslandQrScannerPortal() async {
if (defaultTargetPlatform != TargetPlatform.iOS) {
return false;
}
try {
return await _qrScannerPlatformChannel.invokeMethod<bool>(
'usesDynamicIslandQrScannerPortal',
) ??
false;
} on PlatformException {
return false;
} on MissingPluginException {
return false;
}
}
/// Opens the Dynamic Island QR scanner portal.
///
/// Callers determine device support with
/// [usesDynamicIslandQrScannerPortal]. Standard devices reveal the camera
/// behind their existing app surface with [FallbackPairingQrScanner].
Future<String?> showDynamicIslandPairingQrScanner(BuildContext context) {
return Navigator.of(context).push<String>(
PageRouteBuilder<String>(
opaque: false,
barrierColor: Colors.transparent,
barrierDismissible: false,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) => const _DynamicIslandQrScannerPortal(),
),
);
}
Future<void> _setDynamicIslandScannerStatusBarHidden(bool hidden) async {
try {
await _qrScannerPlatformChannel.invokeMethod<void>(
'setDynamicIslandScannerStatusBarHidden',
hidden,
);
} on PlatformException {
// The scanner still works if the native status-bar bridge is unavailable.
} on MissingPluginException {
// Widget tests and non-iOS embedders do not register the bridge.
}
}
Future<void> _performDynamicIslandQrScanSuccessHaptic() async {
try {
await _qrScannerPlatformChannel.invokeMethod<void>(
'performDynamicIslandQrScanSuccessHaptic',
);
} on PlatformException {
// Scanning should still complete if haptics are unavailable.
} on MissingPluginException {
// Widget tests and non-iOS embedders do not register the bridge.
}
}
String? _firstScannedValue(BarcodeCapture capture) {
for (final barcode in capture.barcodes) {
final value = barcode.rawValue;
if (value != null && value.isNotEmpty) {
return value;
}
}
return null;
}
/// Geometry for the iPhone Dynamic Island scanner portal.
///
/// The collapsed and expanded frames share one top edge. This makes the camera
/// grow down from the hardware cutout instead of scaling around the center of
/// its final square.
@visibleForTesting
class DynamicIslandQrScannerGeometry {
/// Creates geometry for a viewport and its top safe-area inset.
const DynamicIslandQrScannerGeometry({
required this.viewport,
required this.safeAreaTop,
});
/// The full logical-pixel size available to the portal route.
final Size viewport;
/// The top safe-area inset reported by iOS.
final double safeAreaTop;
static const _collapsedWidth = 120.0;
static const _collapsedHeight = 36.0;
static const _fallbackTop = 11.0;
static const _referenceSafeAreaTop = 59.0;
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 =>
_fallbackTop + math.max(0, safeAreaTop - _referenceSafeAreaTop);
/// The portal frame before the opening animation starts.
Rect get collapsedFrame => Rect.fromLTWH(
(viewport.width - _collapsedWidth) / 2,
top,
_collapsedWidth,
_collapsedHeight,
);
/// The square camera frame after the opening animation completes.
Rect get expandedFrame {
final maxHeight = math.max(
_collapsedHeight,
viewport.height - top - _edgeMargin,
);
final size = math.min(viewport.width - (_edgeMargin * 2), maxHeight);
return Rect.fromLTWH(_edgeMargin, top, size, size);
}
/// Returns the top-anchored portal frame at [progress].
Rect frameAt(double progress) {
final t = progress.clamp(0.0, 1.0);
final start = collapsedFrame;
final end = expandedFrame;
return Rect.fromLTRB(
lerpDouble(start.left, end.left, t)!,
top,
lerpDouble(start.right, end.right, t)!,
lerpDouble(start.bottom, end.bottom, t)!,
);
}
/// Returns the portal corner radius at [progress].
double cornerRadiusAt(double progress) {
final t = progress.clamp(0.0, 1.0);
return lerpDouble(_collapsedHeight / 2, _expandedCornerRadius, t)!;
}
/// Returns the camera opacity after its delayed entrance.
double scannerOpacityAt(double progress) {
return ((progress - _scannerFadeStart) / (1 - _scannerFadeStart)).clamp(
0.0,
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);
}
}
@@ -0,0 +1,177 @@
part of '../pairing_qr_scanner.dart';
const _dynamicIslandOpenDuration = Duration(milliseconds: 460);
const _dynamicIslandCloseDuration = Duration(milliseconds: 340);
const _dynamicIslandEaseOut = Cubic(0.16, 1, 0.3, 1);
class _DynamicIslandQrScannerPortal extends HookWidget {
const _DynamicIslandQrScannerPortal();
@override
Widget build(BuildContext context) {
final controller = useMemoized(MobileScannerController.new);
final animation = useAnimationController(
duration: _dynamicIslandOpenDuration,
reverseDuration: _dynamicIslandCloseDuration,
);
final isClosing = useState(false);
final canPop = useState(false);
final hasHandledResult = useRef(false);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
useEffect(() {
unawaited(_setDynamicIslandScannerStatusBarHidden(true));
if (reduceMotion) {
animation.value = 1;
} else {
unawaited(animation.forward());
}
return () {
unawaited(_setDynamicIslandScannerStatusBarHidden(false));
unawaited(controller.dispose());
};
}, [animation, controller, reduceMotion]);
Future<void> finish(String? result) async {
canPop.value = true;
await WidgetsBinding.instance.endOfFrame;
if (context.mounted) {
Navigator.of(context).pop(result);
}
}
Future<void> closePortal() async {
if (isClosing.value || hasHandledResult.value) {
return;
}
hasHandledResult.value = true;
isClosing.value = true;
canPop.value = true;
if (reduceMotion) {
animation.value = 0;
await WidgetsBinding.instance.endOfFrame;
} else {
await animation.reverse();
}
if (context.mounted) {
Navigator.of(context).pop();
}
}
void handleDetection(BarcodeCapture capture) {
if (isClosing.value || hasHandledResult.value) {
return;
}
final value = _firstScannedValue(capture);
if (value == null) {
return;
}
hasHandledResult.value = true;
unawaited(_performDynamicIslandQrScanSuccessHaptic());
unawaited(finish(value));
}
return PopScope(
canPop: canPop.value,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
unawaited(closePortal());
}
},
child: Material(
type: MaterialType.transparency,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: closePortal,
child: LayoutBuilder(
builder: (context, constraints) {
final geometry = DynamicIslandQrScannerGeometry(
viewport: constraints.biggest,
safeAreaTop: MediaQuery.viewPaddingOf(context).top,
);
return Stack(
children: [
Positioned.fill(
child: AnimatedBuilder(
animation: animation,
builder: (context, _) => ColoredBox(
color: Colors.black.withValues(
alpha: 0.12 * animation.value,
),
),
),
),
AnimatedBuilder(
animation: animation,
builder: (context, _) {
final progress = _dynamicIslandEaseOut.transform(
animation.value.clamp(0.0, 1.0),
);
final frame = geometry.frameAt(progress);
final scannerOpacity = geometry.scannerOpacityAt(
progress,
);
final introLabelOpacity = isClosing.value
? 0.0
: geometry.introLabelOpacityAt(progress);
final promptOpacity = isClosing.value
? 0.0
: math.max(introLabelOpacity, scannerOpacity);
return Positioned.fromRect(
rect: frame,
child: ClipRRect(
key: const ValueKey(
'dynamic-island-qr-scanner-portal',
),
borderRadius: BorderRadius.circular(
geometry.cornerRadiusAt(progress),
),
child: ColoredBox(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
if (!isClosing.value && scannerOpacity > 0)
Opacity(
opacity: scannerOpacity,
child: _QrScannerCamera(
controller: controller,
onDetect: handleDetection,
),
),
IgnorePointer(
child: Opacity(
opacity: promptOpacity,
child: Center(
child: Text(
'Scan a QR code',
style: context.textTheme.bodyMedium
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
),
);
},
),
],
);
},
),
),
),
);
}
}
@@ -0,0 +1,282 @@
part of '../pairing_qr_scanner.dart';
const _fallbackScannerOpenDuration = Duration(milliseconds: 420);
const _fallbackScannerCloseDuration = Duration(milliseconds: 320);
const _fallbackScannerSheetPeekHeight = 112.0;
const _fallbackScannerSheetCornerRadius = 32.0;
const _fallbackScannerDrawerCurve = Cubic(0.32, 0.72, 0, 1);
/// Reveals the scanner behind the current app surface on standard devices.
///
/// The app surface moves down as a rounded sheet instead of navigating to a
/// separate scanner page. This is used by Android and iPhones without a
/// Dynamic Island.
class FallbackPairingQrScanner extends HookWidget {
const FallbackPairingQrScanner({
required this.appSurface,
required this.onClosed,
super.key,
});
/// The current Buzz screen that moves down to reveal the camera.
final Widget appSurface;
/// Called after the app surface has returned, with a scanned value if any.
final ValueChanged<String?> onClosed;
@override
Widget build(BuildContext context) {
final controller = useMemoized(MobileScannerController.new);
final animation = useAnimationController(
duration: _fallbackScannerOpenDuration,
reverseDuration: _fallbackScannerCloseDuration,
);
final isClosing = useRef(false);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
useEffect(
() => () {
unawaited(controller.dispose());
},
[controller],
);
useEffect(() {
if (reduceMotion) {
animation.value = 1;
} else {
unawaited(animation.forward());
}
return null;
}, [animation, reduceMotion]);
Future<void> closeScanner([String? result]) async {
if (isClosing.value) {
return;
}
isClosing.value = true;
if (reduceMotion) {
animation.value = 0;
await WidgetsBinding.instance.endOfFrame;
} else {
await animation.reverse();
}
if (context.mounted) {
onClosed(result);
}
}
void handleDetection(BarcodeCapture capture) {
if (isClosing.value) {
return;
}
final value = _firstScannedValue(capture);
if (value == null) {
return;
}
unawaited(closeScanner(value));
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
unawaited(closeScanner());
}
},
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Material(
key: const ValueKey('fallback-qr-scanner-reveal'),
color: Colors.black,
child: LayoutBuilder(
builder: (context, constraints) {
final viewport = constraints.biggest;
final sheetTravel = math.max(
0.0,
viewport.height - _fallbackScannerSheetPeekHeight,
);
return Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned.fill(
child: Semantics(
button: true,
label: 'Close QR scanner',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: closeScanner,
child: Stack(
fit: StackFit.expand,
children: [
_QrScannerCamera(
controller: controller,
onDetect: handleDetection,
),
const IgnorePointer(
child: _FallbackQrScannerViewfinder(),
),
],
),
),
),
),
AnimatedBuilder(
animation: animation,
child: appSurface,
builder: (context, child) {
final progress = _fallbackScannerDrawerCurve.transform(
animation.value.clamp(0.0, 1.0),
);
final offset = sheetTravel * progress;
final radius =
_fallbackScannerSheetCornerRadius * progress;
return Positioned(
top: offset,
left: 0,
right: 0,
height: viewport.height,
child: Semantics(
button: true,
label: 'Close QR scanner',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: closeScanner,
child: ClipRRect(
key: const ValueKey(
'fallback-qr-scanner-app-sheet',
),
borderRadius: BorderRadius.vertical(
top: Radius.circular(radius),
),
child: Stack(
fit: StackFit.expand,
children: [
AbsorbPointer(child: child),
if (progress > 0)
Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(
top: Grid.twelve,
),
child: Opacity(
opacity: progress,
child: Container(
width: Grid.md,
height: Grid.half,
decoration: BoxDecoration(
color: context
.colors
.onSurfaceVariant
.withValues(alpha: 0.45),
borderRadius:
BorderRadius.circular(
Grid.half,
),
),
),
),
),
),
],
),
),
),
),
);
},
),
],
);
},
),
),
),
);
}
}
class _FallbackQrScannerViewfinder extends StatelessWidget {
const _FallbackQrScannerViewfinder();
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final size = math.min(
constraints.maxWidth - (Grid.xl * 2),
constraints.maxHeight - 240,
);
return Stack(
fit: StackFit.expand,
children: [
CustomPaint(
painter: _FallbackQrScannerViewfinderPainter(
viewfinderSize: math.max(0, size),
),
),
Align(
alignment: Alignment.center,
child: Transform.translate(
offset: Offset(0, (size / 2) + Grid.md),
child: Text(
'Scan a QR code',
style: context.textTheme.bodyMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
],
);
},
);
}
}
class _FallbackQrScannerViewfinderPainter extends CustomPainter {
const _FallbackQrScannerViewfinderPainter({required this.viewfinderSize});
final double viewfinderSize;
@override
void paint(Canvas canvas, Size size) {
final rect = Rect.fromCenter(
center: size.center(Offset.zero),
width: viewfinderSize,
height: viewfinderSize,
);
final roundedRect = RRect.fromRectAndRadius(
rect,
const Radius.circular(40),
);
canvas.saveLayer(Offset.zero & size, Paint());
canvas.drawRect(
Offset.zero & size,
Paint()..color = Colors.black.withValues(alpha: 0.6),
);
canvas.drawRRect(roundedRect, Paint()..blendMode = BlendMode.clear);
canvas.restore();
canvas.drawRRect(
roundedRect,
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 3,
);
}
@override
bool shouldRepaint(_FallbackQrScannerViewfinderPainter oldDelegate) {
return oldDelegate.viewfinderSize != viewfinderSize;
}
}
@@ -0,0 +1,53 @@
part of '../pairing_qr_scanner.dart';
class _QrScannerCamera extends StatelessWidget {
const _QrScannerCamera({required this.controller, required this.onDetect});
final MobileScannerController controller;
final ValueChanged<BarcodeCapture> onDetect;
@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(
LucideIcons.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,
);
}
}
@@ -0,0 +1,277 @@
import 'dart:async';
import 'package:buzz/features/pairing/pairing_qr_scanner.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
const _qrScannerPlatformChannel = MethodChannel('buzz/qr_scanner');
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final defaultScannerPlatform = MobileScannerPlatform.instance;
late _FakeMobileScannerPlatform fakeScannerPlatform;
setUp(() {
fakeScannerPlatform = _FakeMobileScannerPlatform();
MobileScannerPlatform.instance = fakeScannerPlatform;
});
tearDown(() async {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_qrScannerPlatformChannel, null);
await fakeScannerPlatform.dispose();
MobileScannerPlatform.instance = defaultScannerPlatform;
});
group('DynamicIslandQrScannerGeometry', () {
const viewport = Size(393, 852);
const geometry = DynamicIslandQrScannerGeometry(
viewport: viewport,
safeAreaTop: 59,
);
test('starts at the physical Dynamic Island frame', () {
expect(geometry.collapsedFrame, const Rect.fromLTWH(136.5, 11, 120, 36));
});
test('keeps the top edge fixed while the camera grows down', () {
final start = geometry.frameAt(0);
final middle = geometry.frameAt(0.5);
final end = geometry.frameAt(1);
expect(middle.top, start.top);
expect(end.top, start.top);
expect(middle.bottom, greaterThan(start.bottom));
expect(end.bottom, greaterThan(middle.bottom));
expect(end, const Rect.fromLTWH(15, 11, 363, 363));
});
test('delays the camera until the portal has left the island', () {
expect(geometry.scannerOpacityAt(0.18), 0);
expect(geometry.scannerOpacityAt(0.5), greaterThan(0));
expect(geometry.scannerOpacityAt(1), 1);
});
});
group('usesDynamicIslandQrScannerPortal', () {
test('asks the native bridge on iOS', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async {
expect(call.method, 'usesDynamicIslandQrScannerPortal');
return true;
});
expect(await usesDynamicIslandQrScannerPortal(), isTrue);
});
test('uses the fallback without asking native code on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
var nativeCalls = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async {
nativeCalls += 1;
return true;
});
expect(await usesDynamicIslandQrScannerPortal(), isFalse);
expect(nativeCalls, 0);
});
});
testWidgets('fallback reveals the camera behind the current app surface', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(375, 667));
addTearDown(() => tester.binding.setSurfaceSize(null));
var scannerClosed = false;
await tester.pumpWidget(
MaterialApp(
home: FallbackPairingQrScanner(
appSurface: const ColoredBox(
color: Colors.white,
child: Center(child: Text('Current app surface')),
),
onClosed: (_) {
scannerClosed = true;
},
),
),
);
final sheet = find.byKey(const ValueKey('fallback-qr-scanner-app-sheet'));
expect(tester.getRect(sheet).top, 0);
expect(find.text('Current app surface'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 420));
expect(tester.getRect(sheet).top, closeTo(555, 0.1));
await tester.tapAt(const Offset(187.5, 100));
await tester.pump();
expect(scannerClosed, isFalse);
await tester.pump(const Duration(milliseconds: 320));
await tester.pumpAndSettle();
expect(scannerClosed, isTrue);
expect(tester.getRect(sheet).top, 0);
});
testWidgets(
'portal route opens from the island and an outside tap reverses it closed',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async {
return switch (call.method) {
'usesDynamicIslandQrScannerPortal' => true,
'setDynamicIslandScannerStatusBarHidden' => null,
_ => null,
};
});
await tester.binding.setSurfaceSize(const Size(393, 852));
addTearDown(() => tester.binding.setSurfaceSize(null));
var scannerClosed = false;
await tester.pumpWidget(
MaterialApp(
home: MediaQuery(
data: const MediaQueryData(
size: Size(393, 852),
viewPadding: EdgeInsets.only(top: 59),
),
child: Builder(
builder: (context) => TextButton(
onPressed: () async {
await showDynamicIslandPairingQrScanner(context);
scannerClosed = true;
},
child: const Text('Open scanner'),
),
),
),
),
);
await tester.tap(find.text('Open scanner'));
await tester.pump();
final portal = find.byKey(
const ValueKey('dynamic-island-qr-scanner-portal'),
);
expect(tester.getRect(portal), const Rect.fromLTWH(136.5, 11, 120, 36));
await tester.pump(const Duration(milliseconds: 460));
expect(tester.getRect(portal).top, 11);
expect(
find.byKey(const ValueKey('dynamic-island-qr-scanner-close')),
findsNothing,
);
await tester.tapAt(const Offset(196.5, 700));
await tester.pump();
expect(scannerClosed, isFalse);
await tester.pump(const Duration(milliseconds: 340));
await tester.pumpAndSettle();
expect(scannerClosed, isTrue);
expect(portal, findsNothing);
debugDefaultTargetPlatformOverride = null;
},
);
testWidgets('tapping the scanner closes it without a separate control', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async {
return switch (call.method) {
'usesDynamicIslandQrScannerPortal' => true,
'setDynamicIslandScannerStatusBarHidden' => null,
_ => null,
};
});
await tester.binding.setSurfaceSize(const Size(393, 852));
addTearDown(() => tester.binding.setSurfaceSize(null));
var scannerClosed = false;
await tester.pumpWidget(
MaterialApp(
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: child!,
),
home: Builder(
builder: (context) => TextButton(
onPressed: () async {
await showDynamicIslandPairingQrScanner(context);
scannerClosed = true;
},
child: const Text('Open scanner'),
),
),
),
);
await tester.tap(find.text('Open scanner'));
await tester.pump();
final portal = find.byKey(
const ValueKey('dynamic-island-qr-scanner-portal'),
);
expect(tester.getRect(portal), const Rect.fromLTWH(15, 11, 363, 363));
await tester.tapAt(tester.getCenter(portal));
await tester.pump();
await tester.pumpAndSettle();
expect(scannerClosed, isTrue);
expect(portal, findsNothing);
debugDefaultTargetPlatformOverride = null;
});
}
class _FakeMobileScannerPlatform extends MobileScannerPlatform {
final _barcodes = StreamController<BarcodeCapture?>.broadcast();
var _isDisposed = false;
@override
Stream<BarcodeCapture?> get barcodesStream => _barcodes.stream;
@override
Stream<TorchState> get torchStateStream =>
Stream.value(TorchState.unavailable);
@override
Stream<double> get zoomScaleStateStream => Stream.value(1);
@override
Future<MobileScannerViewAttributes> start(StartOptions startOptions) async {
return const MobileScannerViewAttributes(
cameraDirection: CameraFacing.back,
currentTorchMode: TorchState.unavailable,
size: Size(200, 200),
numberOfCameras: 1,
);
}
@override
Widget buildCameraView() => const SizedBox.expand();
@override
Future<void> stop() async {}
@override
Future<void> dispose() async {
if (_isDisposed) {
return;
}
_isDisposed = true;
await _barcodes.close();
}
}