mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
## Summary Replace the community switch sheet with a two-column picker. Selection enlarges the avatar, waits with the voice-note loader for destination channel content and layout to settle, then follows a curved path into the header. Cache raster icons at a maximum 512-pixel size and accept only resource-free emoji SVGs. Cancel timed-out or disposed downloads without delaying content readiness. Animate Edit badges in place, and open Add Community on the pairing welcome page. Community removal lives only in the switcher’s Edit mode. Keep the flow shared across Android/iOS, with native iOS controls and removal confirmation, and clear navigation backgrounds at rest. ### Related issue Related to #8070 (community icon refresh/caching). ### Testing - 122 focused Flutter regression tests and seven native iOS tests passed after merging the latest navigation changes from `main`. Earlier feature build installed and launched on a physical iPhone. - Full mobile pre-push checks passed; regression coverage includes gradient rendering, rejected/oversized icons, bounded persisted raster decoding, SVG resource rejection and cache migration, timeout/disposal cancellation, disconnected destination channels, degraded unread-history settlement, and switching while icons refresh. Full `just ci` stopped during Rust checks for low disk space. Hosted CI remains required. - [Grid and Edit previews](https://github.com/block/buzz/pull/8076#issuecomment-5977838957). --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
444 lines
16 KiB
Dart
444 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' show sqrt2;
|
|
import 'dart:ui' show ImageFilter;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../../shared/widgets/frosted_app_bar.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../shared/security/sensitive_action_authorizer.dart';
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/widgets/buzz_loading_indicator.dart';
|
|
import '../../shared/widgets/ios_glass_navigation_button.dart';
|
|
import 'pairing_page/onboarding_wordmark.dart';
|
|
import 'pairing_provider.dart';
|
|
import 'pairing_qr_scanner.dart';
|
|
|
|
part 'pairing_page/onboarding_background.dart';
|
|
part 'pairing_page/onboarding_colors.dart';
|
|
part 'pairing_page/onboarding_glass_button.dart';
|
|
part 'pairing_page/pairing_welcome_view.dart';
|
|
|
|
class PairingPage extends HookConsumerWidget {
|
|
/// When true, the pairing page is being used to add a new community
|
|
/// (user is already authenticated with at least one community).
|
|
final bool addingCommunity;
|
|
final bool identityRecoveryOnly;
|
|
|
|
const PairingPage({
|
|
super.key,
|
|
this.addingCommunity = false,
|
|
this.identityRecoveryOnly = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final pairingState = ref.watch(pairingProvider);
|
|
final enrolledBiometrics = ref.watch(enrolledBiometricsProvider);
|
|
final codeController = useTextEditingController();
|
|
final fallbackScannerVisible = useState(false);
|
|
final pairingCodeExpanded = useState(false);
|
|
final isBusy =
|
|
pairingState.status == PairingStatus.connecting ||
|
|
pairingState.status == PairingStatus.transferring ||
|
|
pairingState.status == PairingStatus.storing;
|
|
|
|
// When adding a community and pairing succeeds, pop back.
|
|
if (addingCommunity && pairingState.status == PairingStatus.success) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (context.mounted) {
|
|
ref.read(pairingProvider.notifier).reset();
|
|
Navigator.of(context).pop();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> handleScannerResult(String? code) async {
|
|
if (code != null && context.mounted) {
|
|
if (identityRecoveryOnly &&
|
|
Uri.tryParse(code)?.queryParameters['mode'] != 'recover') {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Scan a desktop recovery code.')),
|
|
);
|
|
return;
|
|
}
|
|
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 isVerifyingSas = pairingState.status == PairingStatus.confirmingSas;
|
|
final onboardingSystemOverlayStyle =
|
|
(context._onboardingIsDark
|
|
? SystemUiOverlayStyle.light
|
|
: SystemUiOverlayStyle.dark)
|
|
.copyWith(statusBarColor: Colors.transparent);
|
|
final pairingAppBar = addingCommunity
|
|
? defaultTargetPlatform == TargetPlatform.iOS
|
|
? PreferredSize(
|
|
preferredSize: const Size.fromHeight(44),
|
|
child: Stack(
|
|
children: [
|
|
FrostedAppBar(
|
|
nativeTitle: identityRecoveryOnly
|
|
? 'Send to Desktop'
|
|
: '',
|
|
frosted: false,
|
|
showBottomDivider: false,
|
|
iconColor: context._onboardingInk,
|
|
|
|
title: identityRecoveryOnly
|
|
? const Text('Send to Desktop')
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
surfaceTintColor: Colors.transparent,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 0,
|
|
foregroundColor: context._onboardingInk,
|
|
systemOverlayStyle: onboardingSystemOverlayStyle,
|
|
leadingWidth: Theme.of(context).platform == TargetPlatform.iOS
|
|
? Grid.quarter + iosGlassChannelHeaderLeadingWidth
|
|
: null,
|
|
leading: Theme.of(context).platform == TargetPlatform.iOS
|
|
? Padding(
|
|
padding: const EdgeInsets.only(left: Grid.quarter),
|
|
child: IosGlassNavigationButton(
|
|
key: const ValueKey('pairing-ios-glass-back'),
|
|
icon: IosGlassNavigationIcon.back,
|
|
semanticLabel: 'Back',
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
width: iosGlassChannelHeaderLeadingWidth,
|
|
buttonCenterX: iosGlassChannelHeaderButtonCenterX,
|
|
foregroundColor: context._onboardingInk,
|
|
),
|
|
)
|
|
: IconButton(
|
|
icon: const Icon(LucideIcons.arrowLeft),
|
|
tooltip: 'Back',
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
title: identityRecoveryOnly
|
|
? Text(
|
|
'Send to Desktop',
|
|
style: context.textTheme.titleMedium?.copyWith(
|
|
color: context._onboardingInk,
|
|
),
|
|
)
|
|
: null,
|
|
)
|
|
: null;
|
|
|
|
final pairingScaffold = isVerifyingSas
|
|
? AnnotatedRegion<SystemUiOverlayStyle>(
|
|
key: const Key('pairing-sas-system-overlay'),
|
|
value: onboardingSystemOverlayStyle,
|
|
child: _OnboardingBackground(
|
|
child: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
|
|
child: _SasVerificationView(
|
|
sasCode: pairingState.sasCode ?? '------',
|
|
confirmed: pairingState.userConfirmedSas,
|
|
sendsIdentityToDesktop:
|
|
pairingState.sendsIdentityToDesktop,
|
|
protectSensitiveActions:
|
|
pairingState.protectSensitiveActions,
|
|
biometricLabel: biometricProtectionLabel(
|
|
defaultTargetPlatform,
|
|
enrolledBiometrics.value ?? const [],
|
|
),
|
|
errorMessage: pairingState.errorMessage,
|
|
onProtectionChanged: (value) => ref
|
|
.read(pairingProvider.notifier)
|
|
.setProtectSensitiveActions(value),
|
|
onConfirm: () =>
|
|
ref.read(pairingProvider.notifier).confirmSas(),
|
|
onDeny: () =>
|
|
ref.read(pairingProvider.notifier).denySas(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: AnnotatedRegion<SystemUiOverlayStyle>(
|
|
key: const Key('pairing-onboarding-system-overlay'),
|
|
value: onboardingSystemOverlayStyle,
|
|
child: _OnboardingBackground(
|
|
child: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
appBar: pairingAppBar,
|
|
body: SafeArea(
|
|
child: _PairingWelcomeView(
|
|
codeController: codeController,
|
|
isBusy: isBusy,
|
|
pairingCodeExpanded: pairingCodeExpanded.value,
|
|
errorMessage: pairingState.status == PairingStatus.error
|
|
? pairingState.errorMessage
|
|
: null,
|
|
onScan: openScanner,
|
|
onTogglePairingCode: () {
|
|
pairingCodeExpanded.value = !pairingCodeExpanded.value;
|
|
},
|
|
onConnect: () {
|
|
final code = codeController.text.trim();
|
|
if (code.isNotEmpty) {
|
|
unawaited(handleScannerResult(code));
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final appSurface = PopScope(
|
|
key: const Key('pairing-pop-scope'),
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (didPop) {
|
|
ref.read(pairingProvider.notifier).reset();
|
|
}
|
|
},
|
|
child: pairingScaffold,
|
|
);
|
|
|
|
if (!fallbackScannerVisible.value) {
|
|
return appSurface;
|
|
}
|
|
|
|
return FallbackPairingQrScanner(
|
|
appSurface: appSurface,
|
|
onClosed: (code) {
|
|
fallbackScannerVisible.value = false;
|
|
unawaited(handleScannerResult(code));
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// SAS verification screen shown during NIP-AB pairing.
|
|
class _SasVerificationView extends StatelessWidget {
|
|
static const _digitSize = 54.0;
|
|
static const _digitGap = 6.0;
|
|
static const _digitGroupGap = 14.0;
|
|
|
|
final String sasCode;
|
|
final bool confirmed;
|
|
final bool sendsIdentityToDesktop;
|
|
final bool protectSensitiveActions;
|
|
final String biometricLabel;
|
|
final String? errorMessage;
|
|
final ValueChanged<bool> onProtectionChanged;
|
|
final VoidCallback onConfirm;
|
|
final VoidCallback onDeny;
|
|
|
|
const _SasVerificationView({
|
|
required this.sasCode,
|
|
required this.confirmed,
|
|
required this.sendsIdentityToDesktop,
|
|
required this.protectSensitiveActions,
|
|
required this.biometricLabel,
|
|
required this.errorMessage,
|
|
required this.onProtectionChanged,
|
|
required this.onConfirm,
|
|
required this.onDeny,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final verificationContent = Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Confirm desktop code',
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.headlineSmall?.copyWith(
|
|
color: context._onboardingInk,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.4,
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.xxs),
|
|
Text(
|
|
sendsIdentityToDesktop
|
|
? 'Make sure the six-digit code matches on both devices. Your full Buzz identity will transfer to the desktop and grant it permanent access. Only continue if you started this recovery.'
|
|
: 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.',
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: context._onboardingMutedInk,
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.md),
|
|
Semantics(
|
|
label:
|
|
'Confirmation code ${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
|
|
child: ExcludeSemantics(
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (var index = 0; index < sasCode.length; index++) ...[
|
|
if (index > 0)
|
|
SizedBox(width: index == 3 ? _digitGroupGap : _digitGap),
|
|
Container(
|
|
key: Key('pairing-sas-code-digit-${index + 1}'),
|
|
width: _digitSize,
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: context._onboardingInputSurface,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: context.colors.primary.withValues(alpha: 0.15),
|
|
),
|
|
),
|
|
child: Text(
|
|
sasCode[index],
|
|
style: context.textTheme.displaySmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: context._onboardingInk,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.sm),
|
|
if (!sendsIdentityToDesktop)
|
|
CheckboxListTile(
|
|
key: const Key('protect-sensitive-actions-checkbox'),
|
|
value: protectSensitiveActions,
|
|
onChanged: confirmed
|
|
? null
|
|
: (value) => onProtectionChanged(value ?? false),
|
|
activeColor: context._onboardingInk,
|
|
checkColor: context._onboardingCtaLabel,
|
|
side: BorderSide(color: context._onboardingInk),
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(
|
|
biometricLabel,
|
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
color: context._onboardingInk,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
'For secure actions',
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context._onboardingMutedInk,
|
|
),
|
|
),
|
|
),
|
|
if (errorMessage != null) ...[
|
|
const SizedBox(height: Grid.xs),
|
|
Text(
|
|
errorMessage!,
|
|
textAlign: TextAlign.center,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context._onboardingErrorInk,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
|
|
final verificationActions = confirmed
|
|
? Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
BuzzLoadingIndicator(
|
|
size: 24,
|
|
color: context._onboardingInk,
|
|
semanticLabel: 'Connecting',
|
|
),
|
|
const SizedBox(width: Grid.twelve),
|
|
Text(
|
|
'Confirmed — waiting for desktop',
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context._onboardingMutedInk,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: SizedBox(
|
|
width: double.infinity,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
FilledButton.icon(
|
|
style: context._onboardingButtonStyle,
|
|
onPressed: onConfirm,
|
|
icon: const Icon(LucideIcons.check),
|
|
label: const Text('Codes match'),
|
|
),
|
|
const SizedBox(height: Grid.xxs),
|
|
TextButton(
|
|
style: context._onboardingSecondaryButtonStyle.copyWith(
|
|
minimumSize: const WidgetStatePropertyAll(
|
|
Size.fromHeight(48),
|
|
),
|
|
),
|
|
onPressed: onDeny,
|
|
child: const Text('Cancel'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final verticalPadding = Grid.sm * 2;
|
|
final minimumContentHeight =
|
|
constraints.maxHeight > verticalPadding
|
|
? constraints.maxHeight - verticalPadding
|
|
: 0.0;
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.sm),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minHeight: minimumContentHeight),
|
|
child: Center(child: verificationContent),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
verificationActions,
|
|
const SizedBox(height: Grid.sm),
|
|
],
|
|
);
|
|
}
|
|
}
|