Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions mobile/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
22 changes: 22 additions & 0 deletions mobile/ios/RunnerTests/RunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
122 changes: 37 additions & 85 deletions mobile/lib/features/pairing/pairing_page.dart
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 ||
Expand All @@ -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();
Expand Down Expand Up @@ -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'),
),
Expand Down Expand Up @@ -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));
},
);
}
}
Expand Down Expand Up @@ -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);
}
}
},
),
);
}
}
Loading
Loading