Files
buzz/mobile/lib/features/pairing/pairing_page.dart
klopez4212 d2da104d94 feat(mobile): add animated community switcher (#8076)
## 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>
2026-10-04 14:11:39 +01:00

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),
],
);
}
}