Files
buzz/mobile/lib/features/pairing/pairing_qr_scanner.dart
klopez4212 ff9eb8f4dc Make mobile onboarding QR-first and synchronize scanner motion (#8599)
Make QR scanning the primary onboarding action, with full-width
contrasting buttons inside the existing gutters. The landing offers
**Scan a QR code** and **or sign in with email**. A two-finger double
tap on the open welcome area reveals **Enter a community link** and
**Use a pairing code**; a screen-reader action provides the same access.
Action buttons remain outside double-tap detection so scanner taps
respond immediately. Pending sign-in keeps its cancellation action.

The Dynamic Island scanner uses 230 ms opening and closing springs.
Welcome content fades and scales with the same progress, camera startup
begins on the first frame, the scanner prompt fades with the camera
reveal, the live preview adds a 230 ms fade when camera startup
completes, and opening gives selection haptics. Native iOS status
indicators request a matching 230 ms fade, returning when dismissal
begins. Reduced motion is respected.

Validation: full mobile suites and analysis run through local checks and
pre-push hooks. The 230 ms iOS Release build was signed, installed, and
launched in the separate Onboarding app on iPhone Air. The owner
reviewed the simplified landing and immediate tap response; the latest
230 ms timing is installed for comparison. The latest 230 ms build,
including the prompt fade, is installed and launched for comparison.
Native status-bar timing has not been independently measured on-device.


Product intent confirmed by Kenny: the supported visible onboarding
choices are email sign-in or QR scanning. Camera permission/startup
failure intentionally does not expose manual pairing or community-link
recovery. Those remain hidden developer options, with no keyboard path
by design.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-27 16:32:04 +00:00

176 lines
5.3 KiB
Dart

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/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';
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, {
ValueChanged<double>? onProgress,
}) {
return Navigator.of(context).push<String>(
PageRouteBuilder<String>(
opaque: false,
barrierColor: Colors.transparent,
barrierDismissible: false,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
pageBuilder: (_, _, _) =>
_DynamicIslandQrScannerPortal(onProgress: onProgress),
),
);
}
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;
/// 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,
);
}
}