mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user