mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
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>
176 lines
5.3 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|