From e922dc08ffd08f21d20fa0900f5f109eb0a22c98 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 29 Oct 2026 16:12:31 +0000 Subject: [PATCH] Unify mobile onboarding and community entry screens (#8687) ## Summary Use consistent fullscreen screens for identity setup, community selection, joining, and Add Community. Restore all memberships and open the selected community; share the community grid with the switcher. Add QR-first choices, animated link entry with 12px keyboard clearance, a two-finger double-tap pairing toggle, and the centered reversing bee loader. Preserve cancellation, Back, and reduced-motion behavior. Add intentionally omits community creation and Builderlab sign-in: this route follows sign-in and the existing-community picker. ### Related issue Related: #8628 (native profile setup). No duplicate fullscreen onboarding PR found. ### Testing - Full standard and hosted-authentication mobile suites, static analysis, and required push checks passed. - iPhone preview iterated with Kenny; final simulator walkthrough below uses synthetic data and does not authenticate or save account changes. - Independent review completed; regressions cover cancellation during loading, Add recovery after failed/conflicting memberships, retrying skipped communities, and explicit picker stages. Walkthrough recorded before the copy and recovery followups: https://github.com/user-attachments/assets/bfea3ad3-1dde-40c4-8748-44c84226c292 --------- Signed-off-by: kenny lopez Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- mobile/ios/Runner/NativeProfileSetup.swift | 53 ++- .../lib/features/channels/channels_page.dart | 3 + .../channels_page/community_switcher.dart | 195 ++------- .../hosted_community_selection.dart | 98 +++-- .../onboarding/hosted_connect_page.dart | 156 +++++-- .../onboarding/hosted_connect_sheet.dart | 391 ++++++++++-------- .../onboarding/hosted_join_policy_view.dart | 106 +++-- .../onboarding/self_hosted_entry_sheet.dart | 57 ++- .../features/pairing/add_community_page.dart | 106 +++++ .../add_community_connection.dart | 208 ++++++++++ .../add_community_form.dart | 67 +++ .../pairing_reveal_surface.dart | 90 ++++ .../pairing/community_link_focus.dart | 59 +++ mobile/lib/features/pairing/pairing_page.dart | 121 ++++-- .../pairing_page/community_link_form.dart | 23 +- .../onboarding_connection_view.dart | 57 +-- .../pairing_page/sas_verification_view.dart | 66 +-- mobile/lib/hosted_app_gate.dart | 33 +- mobile/lib/onboarding_app_entry.dart | 69 ++-- .../lib/shared/community/community_grid.dart | 35 ++ .../shared/community/community_grid_add.dart | 51 +++ .../community/community_grid_option.dart | 92 +++++ mobile/lib/shared/hosted/hosted_flow.dart | 97 ++++- .../shared/hosted/hosted_flow_provider.dart | 22 +- .../identity_transfer/nsec_import_sheet.dart | 187 +++++---- .../shared/theme/onboarding_typography.dart | 19 + mobile/lib/shared/theme/theme.dart | 1 + .../lib/shared/widgets/bee_loading_mark.dart | 168 ++++++++ .../widgets/ios_community_link_input.dart | 48 +++ .../widgets/onboarding_loading_page.dart | 80 ++++ .../shared/widgets/onboarding_step_page.dart | 214 ++++++++++ .../features/channels/channels_page_test.dart | 2 +- .../fullscreen_onboarding_test.dart | 340 +++++++++++++++ .../onboarding/hosted_communities_test.dart | 30 +- .../onboarding/hosted_journeys_test.dart | 112 +++-- .../onboarding_entry_routes_test.dart | 88 ++-- .../features/pairing/pairing_page_test.dart | 12 +- .../pairing/pairing_qr_scanner_test.dart | 168 ++++---- .../helpers/hosted_communities_fixture.dart | 6 + .../test/helpers/two_finger_double_tap.dart | 16 + .../hosted/hosted_communities_flow_test.dart | 34 +- 41 files changed, 2923 insertions(+), 857 deletions(-) create mode 100644 mobile/lib/features/pairing/add_community_page.dart create mode 100644 mobile/lib/features/pairing/add_community_page/add_community_connection.dart create mode 100644 mobile/lib/features/pairing/add_community_page/add_community_form.dart create mode 100644 mobile/lib/features/pairing/add_community_page/pairing_reveal_surface.dart create mode 100644 mobile/lib/features/pairing/community_link_focus.dart create mode 100644 mobile/lib/shared/community/community_grid.dart create mode 100644 mobile/lib/shared/community/community_grid_add.dart create mode 100644 mobile/lib/shared/community/community_grid_option.dart create mode 100644 mobile/lib/shared/theme/onboarding_typography.dart create mode 100644 mobile/lib/shared/widgets/bee_loading_mark.dart create mode 100644 mobile/lib/shared/widgets/ios_community_link_input.dart create mode 100644 mobile/lib/shared/widgets/onboarding_loading_page.dart create mode 100644 mobile/lib/shared/widgets/onboarding_step_page.dart create mode 100644 mobile/test/features/onboarding/fullscreen_onboarding_test.dart create mode 100644 mobile/test/helpers/two_finger_double_tap.dart diff --git a/mobile/ios/Runner/NativeProfileSetup.swift b/mobile/ios/Runner/NativeProfileSetup.swift index 8a4ca2770d..428b560c28 100644 --- a/mobile/ios/Runner/NativeProfileSetup.swift +++ b/mobile/ios/Runner/NativeProfileSetup.swift @@ -300,9 +300,9 @@ private final class NativeProfileSetupController: UIViewController, UITextFieldD NSLayoutConstraint.activate([ scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scroll.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor), - stack.heightAnchor.constraint(greaterThanOrEqualTo: scroll.frameLayoutGuide.heightAnchor, constant: -40), - stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 20), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -20), - stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor, constant: 20), stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor, constant: -20) + stack.heightAnchor.constraint(greaterThanOrEqualTo: scroll.frameLayoutGuide.heightAnchor, constant: -48), + stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 24), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -24), + stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor, constant: 24), stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor, constant: -24) ]) let avatarMinimumHeight = avatarRow.heightAnchor.constraint(greaterThanOrEqualToConstant: avatarSize) avatarMinimumHeight.priority = .defaultHigh @@ -341,8 +341,8 @@ private final class NativeProfileSetupController: UIViewController, UITextFieldD explanationRow.addArrangedSubview(explanation) explanationRow.isLayoutMarginsRelativeArrangement = true stack.addArrangedSubview(explanationRow) - // Match Grid.xxs between onboarding titles and subtitles. - stack.setCustomSpacing(8, after: heading) + // Match the shared onboarding title/subtitle spacing. + stack.setCustomSpacing(16, after: heading) stack.addArrangedSubview(name) status.numberOfLines = 0; status.textColor = .systemRed; status.font = .preferredFont(forTextStyle: .footnote); status.isHidden = true; stack.addArrangedSubview(status) stack.addArrangedSubview(avatarRow) @@ -367,6 +367,41 @@ private final class NativeProfileSetupController: UIViewController, UITextFieldD stack.setCustomSpacing(8, after: submit) showStep(avatar: false, announce: false) } + // Same Inter metrics as OnboardingTypography in Flutter. + private static let interFontName: String? = { + guard let frameworks = Bundle.main.privateFrameworksURL, + let provider = CGDataProvider(url: frameworks.appendingPathComponent("App.framework/flutter_assets/assets/fonts/InterVariable.ttf") as CFURL), + let font = CGFont(provider), let name = font.postScriptName else { return nil } + CTFontManagerRegisterGraphicsFont(font, nil) + return name as String + }() + + private func updateHeadingTypography() { + for (label, size, lineHeight, tracking, style) in [ + (heading, CGFloat(30), CGFloat(36), CGFloat(-1), UIFont.TextStyle.largeTitle), + (explanation, CGFloat(16), CGFloat(24), CGFloat(-0.25), UIFont.TextStyle.body) + ] { + let metrics = UIFontMetrics(forTextStyle: style) + let base = Self.interFontName.flatMap { UIFont(name: $0, size: size) } + ?? UIFont.systemFont(ofSize: size, weight: .regular) + let font = metrics.scaledFont(for: base, compatibleWith: traitCollection) + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = metrics.scaledValue(for: lineHeight, compatibleWith: traitCollection) + label.font = font + label.attributedText = NSAttributedString(string: label.text ?? "", attributes: [ + .font: font, .paragraphStyle: paragraph, + .kern: tracking * font.pointSize / size + ]) + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + updateHeadingTypography() + } + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) focusNameIfNeeded() @@ -381,13 +416,7 @@ private final class NativeProfileSetupController: UIViewController, UITextFieldD explanation.text = avatar ? "Let’s choose an avatar so people know it’s you." : "Your display name is how people see you in Buzz. You can change it any time." - // Mobile bodyLarge subtitle: 16/20, regular, subtle. - let paragraph = NSMutableParagraphStyle() - paragraph.minimumLineHeight = UIFontMetrics(forTextStyle: .subheadline).scaledValue(for: 20) - explanation.attributedText = NSAttributedString(string: explanation.text ?? "", attributes: [ - .paragraphStyle: paragraph, - .kern: 0 - ]) + updateHeadingTypography() explanationRow.directionalLayoutMargins = .zero name.isHidden = avatar avatarRow.isHidden = !avatar diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 7c48b8f457..3fbd0db665 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -20,6 +20,9 @@ import 'package:buzz/shared/theme/buzz_icons.dart'; import '../../shared/auth/auth.dart'; import '../../shared/community/community_icon_provider.dart'; import '../../shared/community/community_avatar.dart'; +import '../../shared/community/community_grid.dart'; +import '../../shared/community/community_grid_option.dart'; +import '../../shared/community/community_grid_add.dart'; import '../../shared/community/paired_community_landing.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/widgets/app_list_card_item.dart'; diff --git a/mobile/lib/features/channels/channels_page/community_switcher.dart b/mobile/lib/features/channels/channels_page/community_switcher.dart index 6074ccb2ef..449f50bd9d 100644 --- a/mobile/lib/features/channels/channels_page/community_switcher.dart +++ b/mobile/lib/features/channels/channels_page/community_switcher.dart @@ -1,6 +1,6 @@ part of '../channels_page.dart'; -const _communityGridAvatarSize = 88.0; +const _communityGridAvatarSize = communityGridAvatarSize; const _communityCenterDuration = Duration(milliseconds: 420); const _communityCenterCurve = Cubic(0.22, 1, 0.36, 1); const _communityDepartureDuration = Duration(milliseconds: 520); @@ -338,11 +338,13 @@ class CommunitySwitcherPage extends HookConsumerWidget { ), ), error: (e, _) => Text('Error loading communities: $e'), - data: (communities) => _CommunityGrid( + data: (communities) => CommunityGrid( children: [ for (final community in communities) - _CommunityGridTile( - community: community, + CommunityGridOption( + id: community.id, + name: community.name, + relayUrl: community.relayUrl, // Keep the current-community marker stable // while the selected avatar leaves the grid. isActive: @@ -350,17 +352,27 @@ class CommunitySwitcherPage extends HookConsumerWidget { (flight.value == null ? activeId : flight.value!.previousCommunityId), - isEditing: isEditing.value, + enabled: !isEditing.value, hidden: flight.value?.community.id == community.id, - onSelect: selectCommunity, - onRemove: () => _confirmRemoveCommunity( - context, - ref, - community, - closeSheetAfterRemoval: community.id == activeId, + onSelect: (origin) => + selectCommunity(community, origin), + badge: _CommunityGridAction( + community: community, + isActive: + community.id == + (flight.value == null + ? activeId + : flight.value!.previousCommunityId), + isEditing: isEditing.value, + onRemove: () => _confirmRemoveCommunity( + context, + ref, + community, + closeSheetAfterRemoval: community.id == activeId, + ), ), ), - _CommunityGridAdd(onTap: addCommunity), + CommunityGridAdd(onTap: addCommunity), ], ), ), @@ -544,162 +556,3 @@ class _CommunityFlyingAvatar extends StatelessWidget { ); } } - -/// Intrinsic-height rows keep two columns without clipping scaled labels. -class _CommunityGrid extends StatelessWidget { - const _CommunityGrid({required this.children}); - - final List children; - - @override - Widget build(BuildContext context) => Column( - key: const Key('community-switcher-options'), - mainAxisSize: MainAxisSize.min, - children: [ - for (var index = 0; index < children.length; index += 2) - Padding( - padding: const EdgeInsets.only(bottom: Grid.md), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: children[index]), - const SizedBox(width: Grid.xs), - Expanded( - child: index + 1 < children.length - ? children[index + 1] - : const SizedBox.shrink(), - ), - ], - ), - ), - ], - ); -} - -class _CommunityGridTile extends HookWidget { - const _CommunityGridTile({ - required this.community, - required this.isActive, - required this.isEditing, - required this.hidden, - required this.onSelect, - required this.onRemove, - }); - - final Community community; - final bool isActive; - final bool isEditing; - final bool hidden; - final Future Function(Community, Rect) onSelect; - final VoidCallback onRemove; - - @override - Widget build(BuildContext context) { - final avatarKey = useMemoized(GlobalKey.new); - return Semantics( - selected: isActive, - child: InkWell( - key: Key('community-switcher-row-${community.id}'), - borderRadius: BorderRadius.circular(Radii.card), - onTap: isEditing - ? null - : () { - final box = avatarKey.currentContext?.findRenderObject(); - if (box is RenderBox) { - unawaited( - onSelect( - community, - box.localToGlobal(Offset.zero) & box.size, - ), - ); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Column( - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Opacity( - opacity: hidden ? 0 : 1, - child: SizedBox( - key: avatarKey, - child: CommunityAvatar( - key: Key('community-switcher-avatar-${community.id}'), - name: community.name, - relayUrl: community.relayUrl, - size: _communityGridAvatarSize, - ), - ), - ), - Positioned( - right: -Grid.twelve, - bottom: -Grid.twelve, - child: _CommunityGridAction( - community: community, - isActive: isActive, - isEditing: isEditing, - onRemove: onRemove, - ), - ), - ], - ), - const SizedBox(height: Grid.xs), - Text( - community.name, - textAlign: TextAlign.center, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _CommunityGridAdd extends StatelessWidget { - const _CommunityGridAdd({required this.onTap}); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) => InkWell( - key: const Key('community-switcher-add'), - borderRadius: BorderRadius.circular(Radii.card), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Column( - children: [ - ClipRSuperellipse( - borderRadius: BorderRadius.circular( - Radii.card * _communityGridAvatarSize / 40, - ), - child: Container( - width: _communityGridAvatarSize, - height: _communityGridAvatarSize, - color: context.controlSurface.withValues(alpha: 0.5), - child: Icon( - BuzzIcons.plus, - size: 32, - color: context.colors.onSurfaceVariant, - ), - ), - ), - const SizedBox(height: Grid.xs), - Text( - 'Add Community', - textAlign: TextAlign.center, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ); -} diff --git a/mobile/lib/features/onboarding/hosted_community_selection.dart b/mobile/lib/features/onboarding/hosted_community_selection.dart index 6917d925cd..38fcf00ffb 100644 --- a/mobile/lib/features/onboarding/hosted_community_selection.dart +++ b/mobile/lib/features/onboarding/hosted_community_selection.dart @@ -4,38 +4,88 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; import '../../shared/theme/grid.dart'; +import '../../shared/community/community_grid.dart'; +import '../../shared/community/community_grid_option.dart'; +import '../../shared/community/community_grid_add.dart'; +import '../../onboarding_app_entry.dart'; +import '../../shared/widgets/onboarding_step_page.dart'; -/// Public community choices only; identity material remains in secure storage. +/// Community selection is completed before admission reveals Home. class HostedCommunitySelection extends HookConsumerWidget { - const HostedCommunitySelection({super.key, required this.flow}); + const HostedCommunitySelection({ + super.key, + required this.flow, + this.onAddCommunity, + this.fullScreen = false, + this.onBack, + }); final HostedFlow flow; + final VoidCallback? onAddCommunity; + final bool fullScreen; + final VoidCallback? onBack; @override Widget build(BuildContext context, WidgetRef ref) { useListenable(flow); - return Material( - type: MaterialType.transparency, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final community in flow.communities) - CheckboxListTile( - key: ValueKey(community.relayUrl), - title: Text(community.name), - subtitle: Text(Uri.parse(community.relayUrl).host), - value: flow.selectedRelays.contains(community.relayUrl), - onChanged: (value) => - flow.selectCommunity(community.relayUrl, value ?? false), - ), - const SizedBox(height: Grid.sm), - FilledButton( - onPressed: flow.selectedRelays.isEmpty - ? null - : flow.addSelectedCommunities, - child: const Text('Continue'), + Future addCommunity() async { + final navigator = Navigator.of(context, rootNavigator: true); + if (!await flow.prepareToAddCommunity() || !navigator.mounted) return; + late final MaterialPageRoute route; + var exiting = false; + Future exit() async { + if (exiting) return; + exiting = true; + await flow.signInAccount(); + if (flow.stage == HostedFlowStage.signIn || + flow.stage == HostedFlowStage.idle) { + exiting = false; + return; + } + if (route.isActive) route.navigator?.removeRoute(route); + } + + route = MaterialPageRoute( + builder: (_) => OnboardingAppEntry( + addingCommunity: true, + restoreAccount: false, + onExit: exit, + ), + ); + navigator.push(route); + // Mount the opaque entry route before retiring the setup cover. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (route.isActive) flow.leave(); + }); + } + + final choices = CommunityGrid( + children: [ + for (final community in flow.communities) + CommunityGridOption( + id: community.relayUrl, + name: community.name, + relayUrl: community.relayUrl, + onSelect: (_) async { + flow.chooseLandingCommunity(community.relayUrl); + await flow.addSelectedCommunities(); + }, ), - ], - ), + CommunityGridAdd(onTap: onAddCommunity ?? addCommunity), + ], + ); + if (fullScreen) { + return OnboardingStepPage( + title: 'Select a community', + onBack: onBack ?? () {}, + content: choices, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + choices, + const SizedBox(height: Grid.sm), + ], ); } } diff --git a/mobile/lib/features/onboarding/hosted_connect_page.dart b/mobile/lib/features/onboarding/hosted_connect_page.dart index d0494e3eb8..8fb117a4f9 100644 --- a/mobile/lib/features/onboarding/hosted_connect_page.dart +++ b/mobile/lib/features/onboarding/hosted_connect_page.dart @@ -1,65 +1,155 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; -import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/theme/theme_extensions.dart'; import 'hosted_connect_sheet.dart'; -/// Presents setup with the same modal chrome as the app's other sheets. +/// Owns a full-screen route above both initial entry and pushed Add Community. class HostedConnectPage extends HookConsumerWidget { const HostedConnectPage({ super.key, required this.flow, + this.onAddCommunity, required this.onClose, + required this.profileBuilder, }); final HostedFlow flow; + final VoidCallback? onAddCommunity; final VoidCallback onClose; + final WidgetBuilder profileBuilder; @override Widget build(BuildContext context, WidgetRef ref) { + final latest = useRef(this)..value = this; useEffect(() { var active = true; - ModalRoute? sheetRoute; - WidgetsBinding.instance.addPostFrameCallback((_) async { + Route? route; + final stepsNavigator = GlobalKey(); + WidgetsBinding.instance.addPostFrameCallback((_) { if (!active || !context.mounted) return; - while (active && - context.mounted && - flow.stage != HostedFlowStage.complete && - flow.stage != HostedFlowStage.idle) { - if (!context.mounted) return; - await showBuzzModalBottomSheet( - context: context, - title: 'Connect to Builderlab', - useRootNavigator: true, - isScrollControlled: true, - useSafeArea: true, - builder: (sheetContext) { - sheetRoute = ModalRoute.of(sheetContext); - return HostedConnectSheet(flow: flow); + route = PageRouteBuilder( + settings: const RouteSettings(name: 'hosted-setup'), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) stepsNavigator.currentState?.maybePop(); }, - ); - if (!active || !context.mounted) return; - if (flow.stage != HostedFlowStage.profile) { - onClose(); - return; - } - // Dismissing an optional profile is a skip. Re-present the continuing - // batch so a later error or choice never ends up behind a closed sheet. - flow.finishProfile(); - await WidgetsBinding.instance.endOfFrame; - } + child: Navigator( + key: stepsNavigator, + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => _HostedSteps( + flow: flow, + onAddCommunity: onAddCommunity, + onClose: () => latest.value.onClose(), + profileBuilder: (context) => + latest.value.profileBuilder(context), + ), + ), + ), + ), + ); + unawaited( + Navigator.of(context, rootNavigator: true).push(route!).then((_) { + if (active && + flow.stage != HostedFlowStage.complete && + flow.stage != HostedFlowStage.idle) { + latest.value.onClose(); + } + }), + ); }); return () { active = false; - final route = sheetRoute; WidgetsBinding.instance.addPostFrameCallback((_) { - if (route != null && route.isActive) { - route.navigator?.removeRoute(route); + final current = route; + if (current != null && current.isActive) { + current.navigator?.removeRoute(current); } }); }; }, [flow]); - return const SizedBox.shrink(); + return ColoredBox(color: context.colors.surface); + } +} + +class _HostedSteps extends HookWidget { + const _HostedSteps({ + required this.flow, + this.onAddCommunity, + required this.onClose, + required this.profileBuilder, + }); + final HostedFlow flow; + final VoidCallback? onAddCommunity; + final VoidCallback onClose; + final WidgetBuilder profileBuilder; + + @override + Widget build(BuildContext context) { + useListenable(flow); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final presentedStage = flow.stage; + final presentation = Object(); + final activePresentation = useRef(null)..value = presentation; + // Keep an opaque surface behind the native profile presentation and its + // dismissal. Completion retires the route only after the flow settles. + if (flow.stage == HostedFlowStage.profile) { + return ColoredBox( + color: context.colors.surface, + child: profileBuilder(context), + ); + } + return ColoredBox( + color: context.colors.surface, + child: AnimatedSwitcher( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 250), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [ + for (final child in previous) + ExcludeFocus( + child: ExcludeSemantics(child: IgnorePointer(child: child)), + ), + ?current, + ], + ), + transitionBuilder: (child, animation) => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + child: HostedConnectSheet( + key: ValueKey(flow.stage), + flow: flow, + onAddCommunity: onAddCommunity, + presentedStage: flow.stage, + fullScreen: true, + onBack: () { + // Outgoing pages remain mounted until their transition finishes. + if (activePresentation.value != presentation || + flow.stage != presentedStage) { + return; + } + if (flow.canChangeCommunities) { + flow.backToCommunityChoices(); + } else { + onClose(); + } + }, + ), + ), + ); } } diff --git a/mobile/lib/features/onboarding/hosted_connect_sheet.dart b/mobile/lib/features/onboarding/hosted_connect_sheet.dart index a8104705cb..c779c45289 100644 --- a/mobile/lib/features/onboarding/hosted_connect_sheet.dart +++ b/mobile/lib/features/onboarding/hosted_connect_sheet.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/widgets/onboarding_step_page.dart'; +import '../../shared/widgets/bee_loading_mark.dart'; +import '../../shared/widgets/onboarding_loading_page.dart'; import '../../shared/identity_transfer/nsec_import_sheet.dart'; import '../../shared/hosted/hosted_flow_problem_message.dart'; import '../../shared/utils/string_utils.dart'; @@ -14,21 +18,48 @@ import 'hosted_profile_setup.dart'; /// Presents the app-scoped real flow without retaining credentials or keys. class HostedConnectSheet extends HookConsumerWidget { - const HostedConnectSheet({super.key, required this.flow}); + const HostedConnectSheet({ + super.key, + required this.flow, + this.onAddCommunity, + this.fullScreen = false, + this.onBack, + this.presentedStage, + }); final HostedFlow flow; + final VoidCallback? onAddCommunity; + final bool fullScreen; + final VoidCallback? onBack; + final HostedFlowStage? presentedStage; @override Widget build(BuildContext context, WidgetRef ref) { useListenable(flow); + final stage = presentedStage ?? flow.stage; final showLinkedIdentity = useState(false); Future importIdentity() async { Future? pendingImport; - await showNsecImportSheet( - context, - mismatchMessage: - 'This key doesn’t match the identity linked to your Builderlab account.', - onImport: (nsec) => pendingImport = flow.importIdentity(nsec), - ); + Future save(String nsec) => + pendingImport = flow.importIdentity(nsec); + const mismatch = + 'This key doesn’t match the identity linked to your Builderlab account.'; + if (fullScreen) { + await Navigator.of(context).push( + CupertinoPageRoute( + builder: (_) => NsecImportSheet( + onImport: save, + mismatchMessage: mismatch, + fullScreen: true, + ), + ), + ); + } else { + await showNsecImportSheet( + context, + mismatchMessage: mismatch, + onImport: save, + ); + } // Dismissing the sheet does not cancel a secure-store write in flight. // Finish that operation before resuming, even when the modal returned null. if (pendingImport == null) return; @@ -46,8 +77,8 @@ class HostedConnectSheet extends HookConsumerWidget { minimumSize: const Size.fromHeight(52), shape: const StadiumBorder(), ); - final working = flow.stage == HostedFlowStage.working; - final message = switch (flow.stage) { + final working = stage == HostedFlowStage.working; + final message = switch (stage) { HostedFlowStage.idle => 'Continue connecting your account.', HostedFlowStage.working => flow.addingCommunityName == null @@ -55,9 +86,9 @@ class HostedConnectSheet extends HookConsumerWidget { : 'Adding ${flow.addingCommunityName}…', HostedFlowStage.setupIdentity => flow.canCreateIdentity - ? 'Create an identity, or bring yours from another device. We’ll back it up for future sign-ins.' + ? 'Your Buzz identity is how communities recognize you. Create a new one, or import your existing identity if you already use Buzz.' : 'Your identity hasn’t been backed up yet. Bring it from your other device to keep using the same Buzz account.', - HostedFlowStage.chooseCommunities => 'Choose your communities', + HostedFlowStage.chooseCommunities => 'Select a community', HostedFlowStage.communityConflict => '${flow.addingCommunityName} is already saved with a different identity. ' 'Keep that identity and skip this addition, or return to your choices?', @@ -77,161 +108,193 @@ class HostedConnectSheet extends HookConsumerWidget { HostedFlowStage.complete => 'Your community is ready.', HostedFlowStage.blocked => hostedFlowProblemMessage(flow.problem), }; - return Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - Grid.sm, - Grid.gutter, - Grid.lg, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (flow.stage == HostedFlowStage.setupIdentity) ...[ - Text( - flow.canCreateIdentity - ? 'Make yourself at home' - : 'Bring your identity with you', - style: context.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Grid.twelve), - ], - Text(message, style: context.textTheme.bodyLarge), - const SizedBox(height: Grid.sm), - for (final choice in flow.identityChoices) - Padding( - padding: const EdgeInsets.only(bottom: Grid.sm), - child: OutlinedButton( - onPressed: () => flow.chooseIdentity(choice.pubkey), - child: Padding( - padding: const EdgeInsets.all(Grid.sm), - child: Column( - children: [ - Text(choice.label), - Text(fullNpub(choice.pubkey) ?? choice.pubkey), - ], - ), - ), - ), - ), - if (flow.stage == HostedFlowStage.setupIdentity) ...[ - if (flow.canCreateIdentity) ...[ - FilledButton( - style: actionStyle.copyWith( - backgroundColor: WidgetStatePropertyAll( - context.colors.primary, - ), - foregroundColor: WidgetStatePropertyAll( - context.colors.onPrimary, - ), - ), - onPressed: flow.createIdentity, - child: const Text('Create my Buzz identity'), - ), - const SizedBox(height: Grid.xxs), - TextButton( - style: actionStyle, - onPressed: importIdentity, - child: const Text('Import existing identity'), - ), - ] else ...[ - FilledButton( - style: actionStyle.copyWith( - backgroundColor: WidgetStatePropertyAll( - context.colors.primary, - ), - foregroundColor: WidgetStatePropertyAll( - context.colors.onPrimary, - ), - ), - onPressed: importIdentity, - child: const Text('Import existing identity'), - ), - const SizedBox(height: Grid.twelve), - Text( - 'We’ll securely back it up so your next sign-in is easier.', - textAlign: TextAlign.center, - style: context.textTheme.bodySmall, - ), - ], - if (flow.requiredIdentityPubkey != null) ...[ - const SizedBox(height: Grid.xxs), - TextButton( - onPressed: () => - showLinkedIdentity.value = !showLinkedIdentity.value, - child: Text( - showLinkedIdentity.value - ? 'Hide linked identity' - : 'View linked identity', - ), - ), - if (showLinkedIdentity.value) - SelectableText( - fullNpub(flow.requiredIdentityPubkey!) ?? - flow.requiredIdentityPubkey!, - style: context.textTheme.bodySmall, - ), - ], - ], - if (flow.stage == HostedFlowStage.profile && - flow.saveProfile != null) - HostedProfileSetup( - save: flow.saveProfile!, - skip: flow.finishProfile, - ), - if (flow.stage == HostedFlowStage.acceptPolicy && - flow.joinPolicy != null) - HostedJoinPolicyView( - key: ValueKey(flow.joinPolicy!.version), - policy: flow.joinPolicy!, - onAccept: flow.acceptJoinPolicy, - ), - if (flow.stage == HostedFlowStage.chooseCommunities) - HostedCommunitySelection(flow: flow), - if (flow.stage == HostedFlowStage.communityConflict) - FilledButton( - onPressed: flow.skipConflictingCommunity, - child: const Text('Keep saved identity and skip'), - ), - if (flow.canChangeCommunities) - OutlinedButton( - onPressed: flow.backToCommunityChoices, - child: const Text('Back to choices'), - ), - if ((flow.stage == HostedFlowStage.blocked || - flow.stage == HostedFlowStage.communityConflict) && - flow.addedCommunityCount > 0) - Text( - '${flow.addedCommunityCount} ' - '${flow.addedCommunityCount == 1 ? "community" : "communities"} added and kept.' - '${flow.canRetry && flow.stage == HostedFlowStage.blocked ? " Retry continues the remaining additions." : ""}', - ), - if (working) const Center(child: CircularProgressIndicator()), - if (!working && - flow.stage != HostedFlowStage.profile && - flow.stage != HostedFlowStage.acceptPolicy && - flow.stage != HostedFlowStage.chooseIdentity && - flow.stage != HostedFlowStage.setupIdentity && - flow.stage != HostedFlowStage.chooseCommunities && - flow.stage != HostedFlowStage.communityConflict && - flow.stage != HostedFlowStage.complete && - flow.stage != HostedFlowStage.browserReturned && - flow.canRetry) - FilledButton( - onPressed: flow.retry, - child: Text( - flow.stage == HostedFlowStage.signIn - ? 'Start login again' - : 'Try again', - ), - ), - ], + if (fullScreen && stage == HostedFlowStage.chooseCommunities) { + return HostedCommunitySelection( + flow: flow, + fullScreen: true, + onAddCommunity: onAddCommunity, + onBack: onBack, + ); + } + if (fullScreen && + stage == HostedFlowStage.acceptPolicy && + flow.joinPolicy != null) { + return HostedJoinPolicyView( + key: ValueKey(flow.joinPolicy!.version), + policy: flow.joinPolicy!, + onAccept: flow.acceptJoinPolicy, + fullScreen: true, + onBack: onBack, + ); + } + final children = [ + if (!fullScreen && stage == HostedFlowStage.setupIdentity) ...[ + Text( + flow.canCreateIdentity + ? 'Create or import a Buzz identity' + : 'Bring your identity with you', + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), ), + const SizedBox(height: Grid.twelve), + ], + if (!fullScreen) Text(message, style: context.textTheme.bodyLarge), + const SizedBox(height: Grid.sm), + for (final choice in flow.identityChoices) + Padding( + padding: const EdgeInsets.only(bottom: Grid.sm), + child: OutlinedButton( + onPressed: () => flow.chooseIdentity(choice.pubkey), + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + children: [ + Text(choice.label), + Text(fullNpub(choice.pubkey) ?? choice.pubkey), + ], + ), + ), + ), + ), + if (stage == HostedFlowStage.setupIdentity) ...[ + if (flow.canCreateIdentity) ...[ + FilledButton( + style: actionStyle.copyWith( + backgroundColor: WidgetStatePropertyAll(context.colors.primary), + foregroundColor: WidgetStatePropertyAll(context.colors.onPrimary), + ), + onPressed: flow.createIdentity, + child: const Text('Create my Buzz identity'), + ), + const SizedBox(height: Grid.xxs), + TextButton( + style: actionStyle, + onPressed: importIdentity, + child: const Text('Import existing identity'), + ), + ] else ...[ + FilledButton( + style: actionStyle.copyWith( + backgroundColor: WidgetStatePropertyAll(context.colors.primary), + foregroundColor: WidgetStatePropertyAll(context.colors.onPrimary), + ), + onPressed: importIdentity, + child: const Text('Import existing identity'), + ), + const SizedBox(height: Grid.twelve), + Text( + 'We’ll securely back it up so your next sign-in is easier.', + textAlign: TextAlign.center, + style: context.textTheme.bodySmall, + ), + ], + if (flow.requiredIdentityPubkey != null) ...[ + const SizedBox(height: Grid.xxs), + TextButton( + onPressed: () => + showLinkedIdentity.value = !showLinkedIdentity.value, + child: Text( + showLinkedIdentity.value + ? 'Hide linked identity' + : 'View linked identity', + ), + ), + if (showLinkedIdentity.value) + SelectableText( + fullNpub(flow.requiredIdentityPubkey!) ?? + flow.requiredIdentityPubkey!, + style: context.textTheme.bodySmall, + ), + ], + ], + if (stage == HostedFlowStage.profile && flow.saveProfile != null) + HostedProfileSetup(save: flow.saveProfile!, skip: flow.finishProfile), + if (stage == HostedFlowStage.acceptPolicy && flow.joinPolicy != null) + HostedJoinPolicyView( + key: ValueKey(flow.joinPolicy!.version), + policy: flow.joinPolicy!, + onAccept: flow.acceptJoinPolicy, + ), + if (stage == HostedFlowStage.chooseCommunities) + HostedCommunitySelection(flow: flow, onAddCommunity: onAddCommunity), + if (stage == HostedFlowStage.communityConflict) + FilledButton( + onPressed: flow.skipConflictingCommunity, + child: const Text('Keep saved identity and skip'), + ), + if (flow.canSkipFailedCommunity) + OutlinedButton( + onPressed: flow.skipFailedCommunity, + child: const Text('Skip this community'), + ), + if (flow.canChangeCommunities) + OutlinedButton( + onPressed: flow.backToCommunityChoices, + child: const Text('Back to choices'), + ), + if ((stage == HostedFlowStage.blocked || + stage == HostedFlowStage.communityConflict) && + flow.addedCommunityCount > 0) + Text( + '${flow.addedCommunityCount} ' + '${flow.addedCommunityCount == 1 ? "community" : "communities"} added and kept.' + '${flow.canRetry && stage == HostedFlowStage.blocked ? " Retry continues the remaining additions." : ""}', + ), + if (working && !fullScreen) const Center(child: BeeLoadingMark()), + if (!working && + stage != HostedFlowStage.profile && + stage != HostedFlowStage.acceptPolicy && + stage != HostedFlowStage.chooseIdentity && + stage != HostedFlowStage.setupIdentity && + stage != HostedFlowStage.chooseCommunities && + stage != HostedFlowStage.communityConflict && + stage != HostedFlowStage.complete && + stage != HostedFlowStage.browserReturned && + flow.canRetry) + FilledButton( + onPressed: flow.retry, + child: Text( + stage == HostedFlowStage.signIn ? 'Start login again' : 'Try again', + ), + ), + ]; + if (!fullScreen) { + return SingleChildScrollView( + padding: const EdgeInsets.all(Grid.gutter), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + } + final title = switch (stage) { + HostedFlowStage.setupIdentity => + flow.canCreateIdentity + ? 'Create or import a Buzz identity' + : 'Bring your identity with you', + HostedFlowStage.chooseIdentity => 'Choose your identity', + HostedFlowStage.working => + flow.addingCommunityName == null + ? 'Getting ready…' + : 'Joining your community', + HostedFlowStage.blocked => 'Let’s try that again', + HostedFlowStage.communityConflict => 'Keep your saved identity', + HostedFlowStage.browserReturned => 'Continue in your browser', + _ => 'Connect to Builderlab', + }; + if (working) return OnboardingLoadingPage(title: title, onCancel: onBack); + bool isAction(Widget widget) => widget is ButtonStyleButton; + return OnboardingStepPage( + title: title, + subtitle: message, + onBack: onBack ?? () {}, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children.where((widget) => !isAction(widget)).toList(), ), + actions: children.where(isAction).toList(), ); } } diff --git a/mobile/lib/features/onboarding/hosted_join_policy_view.dart b/mobile/lib/features/onboarding/hosted_join_policy_view.dart index 8900e49ae0..5b1c0ab23e 100644 --- a/mobile/lib/features/onboarding/hosted_join_policy_view.dart +++ b/mobile/lib/features/onboarding/hosted_join_policy_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import '../../shared/hosted/hosted_join_policy.dart'; import '../../shared/theme/grid.dart'; +import '../../shared/widgets/onboarding_step_page.dart'; /// Consent is explicit and scoped to the policy version shown on this screen. class HostedJoinPolicyView extends HookWidget { @@ -9,53 +10,84 @@ class HostedJoinPolicyView extends HookWidget { super.key, required this.policy, required this.onAccept, + this.fullScreen = false, + this.onBack, }); final HostedJoinPolicy policy; final VoidCallback onAccept; + final bool fullScreen; + final VoidCallback? onBack; @override Widget build(BuildContext context) { final ageConfirmed = useState(false); - return Material( - type: MaterialType.transparency, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (policy.terms != null) - ExpansionTile( - title: const Text('Terms of service'), - children: [ - Padding( - padding: const EdgeInsets.all(Grid.sm), - child: SelectableText(policy.terms!), - ), - ], + final documents = [ + if (policy.terms != null) 'terms', + if (policy.privacy != null) 'privacy policy', + ]; + final children = [ + if (policy.terms != null) + ExpansionTile( + tilePadding: fullScreen ? EdgeInsets.zero : null, + expandedCrossAxisAlignment: fullScreen + ? CrossAxisAlignment.stretch + : null, + title: const Text('Terms of service'), + children: [ + Padding( + padding: fullScreen + ? const EdgeInsets.symmetric(vertical: Grid.sm) + : const EdgeInsets.all(Grid.sm), + child: SelectableText(policy.terms!), ), - if (policy.privacy != null) - ExpansionTile( - title: const Text('Privacy policy'), - children: [ - Padding( - padding: const EdgeInsets.all(Grid.sm), - child: SelectableText(policy.privacy!), - ), - ], + ], + ), + if (policy.privacy != null) + ExpansionTile( + tilePadding: fullScreen ? EdgeInsets.zero : null, + expandedCrossAxisAlignment: fullScreen + ? CrossAxisAlignment.stretch + : null, + title: const Text('Privacy policy'), + children: [ + Padding( + padding: fullScreen + ? const EdgeInsets.symmetric(vertical: Grid.sm) + : const EdgeInsets.all(Grid.sm), + child: SelectableText(policy.privacy!), ), - if (policy.ageRequired) - CheckboxListTile( - value: ageConfirmed.value, - onChanged: (value) => ageConfirmed.value = value ?? false, - title: const Text('I am 18 or older'), - ), - const SizedBox(height: Grid.sm), - FilledButton( - onPressed: !policy.ageRequired || ageConfirmed.value - ? onAccept - : null, - child: const Text('Accept and join'), - ), - ], + ], + ), + if (policy.ageRequired) + CheckboxListTile( + contentPadding: fullScreen ? EdgeInsets.zero : null, + value: ageConfirmed.value, + onChanged: (value) => ageConfirmed.value = value ?? false, + title: const Text('I am 18 or older'), + ), + const SizedBox(height: Grid.sm), + FilledButton( + onPressed: !policy.ageRequired || ageConfirmed.value ? onAccept : null, + child: const Text('Accept and join'), ), + ]; + if (fullScreen) { + return OnboardingStepPage( + title: 'Join your community', + subtitle: documents.isEmpty + ? null + : 'Review this community’s ${documents.join(' and ')} before joining.', + onBack: onBack ?? () {}, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children.sublist(0, children.length - 1), + ), + actions: [children.last], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, ); } } diff --git a/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart index d1f2dc1e29..bd381ad01b 100644 --- a/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart +++ b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart @@ -11,6 +11,7 @@ import '../../shared/identity_transfer/native_community_entry.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/onboarding_step_page.dart'; import '../../shared/widgets/buzz_titled_sheet_layout.dart'; /// Local preparation seam; the production service validates and retains keys. @@ -23,7 +24,20 @@ Future showSelfHostedEntry( BuildContext context, { required String relay, required String name, + bool fullScreen = false, }) async { + if (fullScreen) { + return await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => SelfHostedEntrySheet( + relay: relay, + name: name, + fullScreen: true, + ), + ), + ) ?? + false; + } if (defaultTargetPlatform == TargetPlatform.iOS) { final container = ProviderScope.containerOf(context); final service = container.read(selfHostedEntryProvider); @@ -88,9 +102,11 @@ class SelfHostedEntrySheet extends HookConsumerWidget { super.key, required this.relay, required this.name, + this.fullScreen = false, }); final String relay; final String name; + final bool fullScreen; @override Widget build(BuildContext context, WidgetRef ref) { @@ -184,7 +200,7 @@ class SelfHostedEntrySheet extends HookConsumerWidget { final saved = choices.data?.choices ?? const []; final joinContent = Padding( - padding: const EdgeInsets.all(Grid.gutter), + padding: fullScreen ? EdgeInsets.zero : const EdgeInsets.all(Grid.gutter), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -228,9 +244,31 @@ class SelfHostedEntrySheet extends HookConsumerWidget { button( onPressed: busy.value ? null - : () { - importFocusReady.value = false; - importing.value = true; + : () async { + if (fullScreen) { + final imported = await Navigator.of(context) + .push( + MaterialPageRoute( + builder: (importContext) => NsecImportSheet( + fullScreen: true, + onBack: () { + generation.value++; + busy.value = false; + error.value = null; + Navigator.of(importContext).pop(false); + }, + onImport: (nsec) => + connect(nsec: nsec, close: false), + ), + ), + ); + if (imported == true && context.mounted) { + Navigator.of(context).pop(true); + } + } else { + importFocusReady.value = false; + importing.value = true; + } }, child: const Text('Import a Nostr key'), ), @@ -256,6 +294,17 @@ class SelfHostedEntrySheet extends HookConsumerWidget { ), ), ); + if (fullScreen) { + return OnboardingStepPage( + title: 'Join community', + subtitle: 'Select an identity to use in this community.', + onBack: () { + generation.value++; + Navigator.of(context).pop(false); + }, + content: joinContent, + ); + } return AnimatedSize( duration: MediaQuery.disableAnimationsOf(context) ? Duration.zero diff --git a/mobile/lib/features/pairing/add_community_page.dart b/mobile/lib/features/pairing/add_community_page.dart new file mode 100644 index 0000000000..9f5aa5daad --- /dev/null +++ b/mobile/lib/features/pairing/add_community_page.dart @@ -0,0 +1,106 @@ +import 'package:flutter/gestures.dart'; +import 'community_link_focus.dart'; +import '../../shared/widgets/ios_community_link_input.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../../shared/deeplink/community_link_input.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/onboarding_step_page.dart'; + +part 'add_community_page/add_community_form.dart'; +part 'add_community_page/add_community_connection.dart'; +part 'add_community_page/pairing_reveal_surface.dart'; + +/// Fullscreen Add Community presentation; connection operations belong to callers. +class AddCommunityPage extends StatelessWidget { + const AddCommunityPage({ + super.key, + required this.onBack, + required this.onCommunityLink, + required this.onPairing, + required this.onScan, + required this.onOpenCommunity, + required this.onSubmitPairing, + this.enteringCommunityLink = false, + this.existingUser = false, + this.scannerProgress = 0, + this.busy = false, + this.canSubmit = true, + this.errorMessage, + this.accountStatus, + this.onInputChanged, + }); + + final VoidCallback onBack; + final VoidCallback? onCommunityLink; + final VoidCallback? onPairing; + final VoidCallback? onScan; + final ValueChanged onOpenCommunity; + final ValueChanged onSubmitPairing; + final bool enteringCommunityLink; + final bool existingUser; + final double scannerProgress; + final bool busy; + final bool canSubmit; + final String? errorMessage; + final String? accountStatus; + final VoidCallback? onInputChanged; + + @override + Widget build(BuildContext context) { + final step = enteringCommunityLink + ? 'link' + : existingUser + ? 'pair' + : 'options'; + final page = step == 'options' || step == 'link' + ? _AddCommunityConnection( + key: const ValueKey('add-community-connection'), + enteringLink: enteringCommunityLink, + scannerProgress: scannerProgress, + accountStatus: accountStatus, + onBack: onBack, + onCommunityLink: onCommunityLink, + onPairing: onPairing, + onScan: onScan, + onOpenCommunity: onOpenCommunity, + busy: busy, + canSubmit: canSubmit, + errorMessage: errorMessage, + onInputChanged: onInputChanged, + ) + : _AddCommunityForm( + key: ValueKey(step), + onBack: onBack, + onSubmitPairing: onSubmitPairing, + busy: busy, + canSubmit: canSubmit, + errorMessage: errorMessage, + onInputChanged: onInputChanged, + ); + return AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 250), + layoutBuilder: (current, previous) => Stack( + fit: StackFit.expand, + children: [ + for (final child in previous) + ExcludeFocus( + child: ExcludeSemantics(child: IgnorePointer(child: child)), + ), + ?current, + ], + ), + transitionBuilder: (child, animation) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + child: page, + ); + } +} diff --git a/mobile/lib/features/pairing/add_community_page/add_community_connection.dart b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart new file mode 100644 index 0000000000..58d8724ad6 --- /dev/null +++ b/mobile/lib/features/pairing/add_community_page/add_community_connection.dart @@ -0,0 +1,208 @@ +part of '../add_community_page.dart'; + +/// The link choice and Continue share one button throughout the transition. +class _AddCommunityConnection extends HookWidget { + const _AddCommunityConnection({ + super.key, + required this.enteringLink, + required this.scannerProgress, + required this.onBack, + required this.onCommunityLink, + required this.onPairing, + required this.onScan, + required this.onOpenCommunity, + required this.busy, + required this.canSubmit, + this.errorMessage, + this.accountStatus, + this.onInputChanged, + }); + + final bool enteringLink; + final double scannerProgress; + final VoidCallback onBack; + final VoidCallback? onCommunityLink; + final VoidCallback? onPairing; + final VoidCallback? onScan; + final ValueChanged onOpenCommunity; + final bool busy; + final bool canSubmit; + final String? errorMessage; + final String? accountStatus; + final VoidCallback? onInputChanged; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(); + final focus = useFocusNode(); + final error = useState(null); + final pairingVisible = useState(false); + void revealPairing() => pairingVisible.value = !pairingVisible.value; + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final ios = defaultTargetPlatform == TargetPlatform.iOS; + final keyboardReady = useCommunityLinkFocus( + context, + enteringLink: enteringLink, + focus: focus, + ); + + void submit() { + if (busy || !canSubmit) return; + final link = parseCommunityLinkInput(controller.text.trim()); + if (link == null) { + error.value = 'Enter a valid community address or invite link.'; + return; + } + focus.unfocus(); + onOpenCommunity(link); + } + + return _PairingRevealSurface( + onReveal: enteringLink || busy || scannerProgress > 0 + ? null + : revealPairing, + child: TweenAnimationBuilder( + tween: Tween(end: enteringLink && (!ios || keyboardReady) ? 1 : 0), + duration: reducedMotion + ? Duration.zero + : Duration(milliseconds: ios ? 200 : 150), + curve: ios ? Curves.easeOutCubic : Curves.easeOutQuad, + builder: (context, progress, _) => OnboardingStepPage( + title: enteringLink ? 'Enter a community link' : 'Add a community', + subtitle: enteringLink + ? 'Paste an invite link or community address.' + : accountStatus ?? 'Scan a QR code or enter a community link.', + onBack: onBack, + foregroundProgress: scannerProgress, + keyboardBottomSpacing: Grid.twelve, + onConnectionOptions: enteringLink || busy ? null : revealPairing, + content: ExcludeSemantics( + excluding: !enteringLink, + child: ExcludeFocus( + excluding: !enteringLink, + child: IgnorePointer( + ignoring: !enteringLink, + child: Opacity( + opacity: progress, + child: Transform.translate( + key: const Key('add-community-link-rise'), + offset: Offset( + 0, + reducedMotion ? 0 : Grid.xl * (1 - progress), + ), + child: ios + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + IosCommunityLinkInput( + controller: controller, + focusNode: focus, + enabled: !busy, + onChanged: (_) { + error.value = null; + onInputChanged?.call(); + }, + onSubmitted: (_) => submit(), + ), + if (error.value ?? errorMessage + case final problem?) + Padding( + padding: const EdgeInsets.only(top: Grid.xs), + child: Text( + problem, + style: TextStyle( + color: context.colors.error, + ), + ), + ), + ], + ) + : TextField( + key: const Key('community-link-input'), + controller: controller, + focusNode: focus, + enabled: !busy, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: 'Community link or address', + hintText: 'community.example.com', + errorText: error.value ?? errorMessage, + ), + onChanged: (_) { + error.value = null; + onInputChanged?.call(); + }, + onSubmitted: (_) => submit(), + ), + ), + ), + ), + ), + ), + actions: [ + ExcludeSemantics( + excluding: enteringLink, + child: ExcludeFocus( + excluding: enteringLink, + child: IgnorePointer( + ignoring: enteringLink, + child: Opacity( + opacity: 1 - progress, + child: FilledButton( + onPressed: busy ? null : onScan, + child: const Text('Scan a QR code'), + ), + ), + ), + ), + ), + FilledButton( + key: const Key('add-community-link-action'), + style: FilledButton.styleFrom( + backgroundColor: Color.lerp( + context.colors.surface, + context.colors.primary, + progress, + ), + foregroundColor: Color.lerp( + context.colors.primary, + context.colors.onPrimary, + progress, + ), + side: BorderSide( + color: context.colors.outline.withValues(alpha: 1 - progress), + ), + ), + onPressed: busy || (enteringLink && !canSubmit) + ? null + : enteringLink + ? submit + : onCommunityLink, + child: AnimatedSwitcher( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 150), + child: Text( + enteringLink + ? busy + ? 'Connecting…' + : 'Continue' + : 'Enter a community link', + key: ValueKey(enteringLink), + ), + ), + ), + if (pairingVisible.value && !enteringLink) + OutlinedButton( + onPressed: busy ? null : onPairing, + child: const Text('Use a pairing code'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/pairing/add_community_page/add_community_form.dart b/mobile/lib/features/pairing/add_community_page/add_community_form.dart new file mode 100644 index 0000000000..e700af72d8 --- /dev/null +++ b/mobile/lib/features/pairing/add_community_page/add_community_form.dart @@ -0,0 +1,67 @@ +part of '../add_community_page.dart'; + +class _AddCommunityForm extends HookWidget { + const _AddCommunityForm({ + super.key, + required this.onBack, + required this.onSubmitPairing, + required this.busy, + required this.canSubmit, + this.errorMessage, + this.onInputChanged, + }); + final VoidCallback onBack; + final ValueChanged onSubmitPairing; + final bool busy; + final bool canSubmit; + final String? errorMessage; + final VoidCallback? onInputChanged; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(); + final error = useState(null); + void submit() { + if (busy || !canSubmit) return; + final value = controller.text.trim(); + if (value.isEmpty) { + error.value = 'Enter a pairing code.'; + return; + } + FocusManager.instance.primaryFocus?.unfocus(); + onSubmitPairing(value); + } + + return OnboardingStepPage( + title: 'Use a pairing code', + subtitle: 'Paste the pairing code from your desktop app.', + onBack: onBack, + content: TextField( + key: const Key('community-pairing-input'), + controller: controller, + enabled: !busy, + autofocus: true, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: 'Pairing code', + hintText: 'nostrpair://... or buzz://...', + errorText: error.value ?? errorMessage, + ), + onChanged: (_) { + error.value = null; + onInputChanged?.call(); + }, + onSubmitted: (_) => submit(), + ), + actions: [ + FilledButton( + onPressed: busy || !canSubmit ? null : submit, + child: Text(busy ? 'Connecting…' : 'Connect'), + ), + ], + ); + } +} diff --git a/mobile/lib/features/pairing/add_community_page/pairing_reveal_surface.dart b/mobile/lib/features/pairing/add_community_page/pairing_reveal_surface.dart new file mode 100644 index 0000000000..30f4e97512 --- /dev/null +++ b/mobile/lib/features/pairing/add_community_page/pairing_reveal_surface.dart @@ -0,0 +1,90 @@ +part of '../add_community_page.dart'; + +/// Observe a developer gesture without delaying Scan's ordinary single tap. +class _PairingRevealSurface extends HookWidget { + const _PairingRevealSurface({required this.child, this.onReveal}); + final Widget child; + final VoidCallback? onReveal; + + @override + Widget build(BuildContext context) { + final tracker = useMemoized(_PairingRevealTracker.new, [onReveal != null]); + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: onReveal == null ? null : tracker.down, + onPointerMove: onReveal == null ? null : tracker.move, + onPointerCancel: tracker.pointerCancel, + onPointerUp: onReveal == null + ? null + : (event) => tracker.up(event, onReveal!), + child: child, + ); + } +} + +class _PairingRevealTracker { + final _active = {}; + final _positions = []; + Duration? _start; + Duration? _previousEnd; + Offset? _previousCenter; + bool _invalid = false; + + void cancel() { + _invalid = true; + _previousEnd = null; + _previousCenter = null; + } + + void down(PointerDownEvent event) { + if (_active.isEmpty) { + _positions.clear(); + _start = event.timeStamp; + _invalid = false; + } + _active[event.pointer] = event; + _positions.add(event.position); + if (_positions.length > 2 || + event.timeStamp - _start! > kDoubleTapTimeout) { + cancel(); + } + } + + void move(PointerMoveEvent event) { + final start = _active[event.pointer]; + if (start != null && + (event.position - start.position).distance > kTouchSlop) { + cancel(); + } + } + + void up(PointerUpEvent event, VoidCallback reveal) { + final start = _active.remove(event.pointer); + if (start == null) return; + if (_positions.length != 2 || + (event.position - start.position).distance > kTouchSlop || + event.timeStamp - _start! > kDoubleTapTimeout) { + cancel(); + } + if (_active.isNotEmpty) return; + if (_invalid) return; + final center = (_positions[0] + _positions[1]) / 2; + final gap = _previousEnd == null ? null : _start! - _previousEnd!; + if (gap != null && + gap >= kDoubleTapMinTime && + gap <= kDoubleTapTimeout && + (center - _previousCenter!).distance <= kDoubleTapSlop) { + _previousEnd = null; + _previousCenter = null; + reveal(); + } else { + _previousEnd = event.timeStamp; + _previousCenter = center; + } + } + + void pointerCancel(PointerCancelEvent event) { + _active.remove(event.pointer); + cancel(); + } +} diff --git a/mobile/lib/features/pairing/community_link_focus.dart b/mobile/lib/features/pairing/community_link_focus.dart new file mode 100644 index 0000000000..09776653ee --- /dev/null +++ b/mobile/lib/features/pairing/community_link_focus.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Start link motion with the native keyboard, with a hardware-keyboard fallback. +bool useCommunityLinkFocus( + BuildContext context, { + required bool enteringLink, + required FocusNode focus, +}) { + final ios = defaultTargetPlatform == TargetPlatform.iOS; + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final keyboardReady = useState(false); + final enteringLinkRef = useRef(enteringLink)..value = enteringLink; + useEffect(() { + const channel = MethodChannel('buzz/community_keyboard'); + Timer? fallback; + var disposed = false; + void beginOpening() { + if (disposed || !enteringLinkRef.value || keyboardReady.value) return; + fallback?.cancel(); + keyboardReady.value = true; + } + + if (ios && enteringLink) { + channel.setMethodCallHandler((call) async { + if (call.method != 'willShow') return; + beginOpening(); + }); + } + if (enteringLink) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (disposed || !context.mounted || !enteringLinkRef.value) return; + focus.requestFocus(); + if (!ios || + reducedMotion || + MediaQuery.viewInsetsOf(context).bottom > 0) { + beginOpening(); + } else { + // Hardware keyboards and older hosts may send no show notification. + fallback = Timer(const Duration(milliseconds: 350), beginOpening); + } + }); + } else { + focus.unfocus(); + keyboardReady.value = false; + } + return () { + disposed = true; + fallback?.cancel(); + if (ios && enteringLink) channel.setMethodCallHandler(null); + }; + }, [enteringLink, ios, reducedMotion]); + + return keyboardReady.value; +} diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 05984efc42..8860dea879 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -1,3 +1,5 @@ +import 'community_link_focus.dart'; +import '../../shared/widgets/ios_community_link_input.dart'; import 'dart:async'; import '../../shared/hosted/hosted_flow_provider.dart'; import '../../shared/hosted/hosted_flow.dart'; @@ -8,8 +10,6 @@ import 'dart:math' show sqrt2; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; -import 'package:flutter/cupertino.dart' - show CupertinoColors, CupertinoTextField; import 'package:flutter/foundation.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import 'package:flutter/services.dart'; @@ -31,6 +31,8 @@ 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 'add_community_page.dart'; +import '../../shared/widgets/onboarding_step_page.dart'; import 'pairing_qr_scanner.dart'; part 'pairing_page/onboarding_background.dart'; @@ -221,7 +223,7 @@ class PairingPage extends HookConsumerWidget { : IconButton( icon: const Icon(BuzzIcons.arrowLeft), tooltip: 'Back', - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(context).maybePop(), ), title: identityRecoveryOnly ? Text( @@ -239,6 +241,25 @@ class PairingPage extends HookConsumerWidget { pairingState.status == PairingStatus.storing || pairingState.status == PairingStatus.success; final destinationRelay = pairingState.destinationRelayUrl; + final verificationView = _SasVerificationView( + key: ValueKey(pairingState.sasCode), + fullScreen: addingCommunity && connectionLanding, + sasCode: pairingState.sasCode ?? '------', + verifyDesktopCode: pairingState.requiresDesktopCode + ? ref.read(pairingProvider.notifier).verifyDesktopCode + : null, + confirmed: pairingState.userConfirmedSas, + sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop, + 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(), + ); final pairingScaffold = showLoading ? CommunityLoadingSurface( key: const Key('pairing-community-loading'), @@ -248,42 +269,70 @@ class PairingPage extends HookConsumerWidget { relayUrl: destinationRelay, ) : isVerifyingSas - ? AnnotatedRegion( - key: const Key('pairing-sas-system-overlay'), - value: onboardingSystemOverlayStyle, - child: _OnboardingBackground( - child: Scaffold( - resizeToAvoidBottomInset: - hostedFlow?.stage != HostedFlowStage.profile, - backgroundColor: Colors.transparent, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.sm), - child: _SasVerificationView( - key: ValueKey(pairingState.sasCode), - sasCode: pairingState.sasCode ?? '------', - verifyDesktopCode: pairingState.requiresDesktopCode - ? ref.read(pairingProvider.notifier).verifyDesktopCode - : null, - confirmed: pairingState.userConfirmedSas, - sendsIdentityToDesktop: - pairingState.sendsIdentityToDesktop, - biometricLabel: biometricProtectionLabel( - defaultTargetPlatform, - enrolledBiometrics.value ?? const [], + ? addingCommunity && connectionLanding + ? verificationView + : AnnotatedRegion( + key: const Key('pairing-sas-system-overlay'), + value: onboardingSystemOverlayStyle, + child: _OnboardingBackground( + child: Scaffold( + resizeToAvoidBottomInset: + hostedFlow?.stage != HostedFlowStage.profile, + backgroundColor: Colors.transparent, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.sm, + ), + child: verificationView, + ), ), - errorMessage: pairingState.errorMessage, - onProtectionChanged: (value) => ref - .read(pairingProvider.notifier) - .setProtectSensitiveActions(value), - onConfirm: () => - ref.read(pairingProvider.notifier).confirmSas(), - onDeny: () => - ref.read(pairingProvider.notifier).denySas(), ), ), - ), - ), + ) + : addingCommunity && connectionLanding + ? ValueListenableBuilder( + valueListenable: islandScannerProgress, + builder: (context, progress, _) => AddCommunityPage( + scannerProgress: progress, + enteringCommunityLink: enteringCommunityLink, + existingUser: existingUser, + onBack: onWelcomeBack ?? () => Navigator.of(context).pop(), + onCommunityLink: onCommunityLink, + onPairing: onAlreadyUseBuzz, + onScan: openScanner, + onOpenCommunity: (uri) { + ref.read(hostedInlineEntryProvider.notifier).setActive(true); + ref.read(pendingDeepLinkProvider.notifier).open(uri); + }, + onSubmitPairing: (code) => unawaited(handleScannerResult(code)), + accountStatus: accountStatus, + busy: + isBusy || + builderlabBusy || + (hostedFlow != null && + !hostedFlow.accountOnly && + (hostedFlow.stage == HostedFlowStage.working || + hostedFlow.stage == HostedFlowStage.signIn)), + canSubmit: + hostedFlow == null || + hostedFlow.accountOnly || + hostedFlow.problem == null || + hostedFlow.canRetry, + errorMessage: + pairingState.errorMessage ?? + (hostedFlow != null && + !hostedFlow.accountOnly && + hostedFlow.problem != null + ? hostedFlowProblemMessage(hostedFlow.problem) + : null), + onInputChanged: () { + if (hostedFlow != null && + !hostedFlow.accountOnly && + hostedFlow.problem != null) { + unawaited(hostedFlow.leave()); + } + }, ), ) : AnnotatedRegion( diff --git a/mobile/lib/features/pairing/pairing_page/community_link_form.dart b/mobile/lib/features/pairing/pairing_page/community_link_form.dart index cb436f09f6..b628f7dab5 100644 --- a/mobile/lib/features/pairing/pairing_page/community_link_form.dart +++ b/mobile/lib/features/pairing/pairing_page/community_link_form.dart @@ -84,40 +84,25 @@ class _CommunityLinkForm extends HookConsumerWidget { if (fullPage && showHeading) ...[ Text( 'Enter a community link', - style: context.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w600, + style: OnboardingTypography.title.copyWith( + color: context.colors.onSurface, ), ), const SizedBox(height: Grid.xs), Text( 'Paste an invite link or community address.', - style: context.textTheme.titleLarge?.copyWith( + style: OnboardingTypography.subtitle.copyWith( color: context.colors.onSurfaceVariant, ), ), const SizedBox(height: Grid.lg), ], if (iosInput) ...[ - CupertinoTextField( - key: const Key('community-link-input'), + IosCommunityLinkInput( controller: controller, focusNode: focusNode, autofocus: fullPage && autofocus, enabled: !busy, - placeholder: 'Community link or address', - placeholderStyle: context.textTheme.titleLarge?.copyWith( - color: CupertinoColors.placeholderText.resolveFrom(context), - ), - keyboardType: TextInputType.url, - textInputAction: TextInputAction.go, - autocorrect: false, - enableSuggestions: false, - padding: const EdgeInsets.symmetric(vertical: Grid.xs), - style: context.textTheme.titleLarge?.copyWith( - color: context.colors.onSurface, - ), - cursorColor: context.colors.primary, - decoration: null, onChanged: changed, onSubmitted: (_) => submit(), ), diff --git a/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart b/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart index 72d3341f57..dbb2f8ebd2 100644 --- a/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart +++ b/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart @@ -23,7 +23,6 @@ class _OnboardingConnectionView extends HookWidget { Widget build(BuildContext context) { final reducedMotion = MediaQuery.disableAnimationsOf(context); final ios = defaultTargetPlatform == TargetPlatform.iOS; - final keyboardReady = useState(false); final staggerEntrance = enteringLink && !ios; // Match the iOS return motion, starting after keyboard preparation. final duration = reducedMotion @@ -35,46 +34,11 @@ class _OnboardingConnectionView extends HookWidget { // A new viewport (for example rotation) gets a fresh measurement. final viewport = MediaQuery.sizeOf(context); final restingHeight = useMemoized(() => [null], [viewport]); - final enteringLinkRef = useRef(enteringLink)..value = enteringLink; - useEffect(() { - const channel = MethodChannel('buzz/community_keyboard'); - Timer? fallback; - var disposed = false; - void beginOpening() { - if (disposed || !enteringLinkRef.value || keyboardReady.value) return; - fallback?.cancel(); - keyboardReady.value = true; - } - - if (ios && enteringLink) { - channel.setMethodCallHandler((call) async { - if (call.method != 'willShow') return; - beginOpening(); - }); - } - if (enteringLink) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (disposed || !context.mounted || !enteringLinkRef.value) return; - focus.requestFocus(); - if (!ios || - reducedMotion || - MediaQuery.viewInsetsOf(context).bottom > 0) { - beginOpening(); - } else { - // Hardware keyboards and older hosts may send no show notification. - fallback = Timer(const Duration(milliseconds: 350), beginOpening); - } - }); - } else { - focus.unfocus(); - keyboardReady.value = false; - } - return () { - disposed = true; - fallback?.cancel(); - if (ios && enteringLink) channel.setMethodCallHandler(null); - }; - }, [enteringLink, ios, reducedMotion]); + final keyboardReady = useCommunityLinkFocus( + context, + enteringLink: enteringLink, + focus: focus, + ); return Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), @@ -105,7 +69,7 @@ class _OnboardingConnectionView extends HookWidget { } return TweenAnimationBuilder( tween: Tween( - end: enteringLink && (!ios || keyboardReady.value) ? 1 : 0, + end: enteringLink && (!ios || keyboardReady) ? 1 : 0, ), duration: duration, curve: ios @@ -355,11 +319,16 @@ class _ConnectionHeading extends StatelessWidget { Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: context.textTheme.headlineSmall), + Text( + title, + style: OnboardingTypography.title.copyWith( + color: context.colors.onSurface, + ), + ), const SizedBox(height: Grid.xs), Text( subtitle, - style: context.textTheme.bodyLarge?.copyWith( + style: OnboardingTypography.subtitle.copyWith( color: context.colors.onSurfaceVariant, ), ), diff --git a/mobile/lib/features/pairing/pairing_page/sas_verification_view.dart b/mobile/lib/features/pairing/pairing_page/sas_verification_view.dart index 454edd4ba1..3afd87d7a1 100644 --- a/mobile/lib/features/pairing/pairing_page/sas_verification_view.dart +++ b/mobile/lib/features/pairing/pairing_page/sas_verification_view.dart @@ -3,6 +3,7 @@ part of '../pairing_page.dart'; /// SAS verification screen shown during NIP-AB pairing. class _SasVerificationView extends HookConsumerWidget { final String sasCode; + final bool fullScreen; final Future Function(String)? verifyDesktopCode; final bool confirmed; final bool sendsIdentityToDesktop; @@ -14,6 +15,7 @@ class _SasVerificationView extends HookConsumerWidget { const _SasVerificationView({ super.key, + this.fullScreen = false, required this.sasCode, this.verifyDesktopCode, required this.confirmed, @@ -124,36 +126,37 @@ class _SasVerificationView extends HookConsumerWidget { color: context._onboardingInk, ), const SizedBox(height: Grid.sm), - Text( - 'Protect your identity', - textAlign: TextAlign.center, - style: context.textTheme.headlineSmall?.copyWith( - color: context._onboardingInk, - fontWeight: FontWeight.w600, + if (!fullScreen) + Text( + 'Protect your identity', + textAlign: TextAlign.center, + style: OnboardingTypography.title.copyWith( + color: context._onboardingInk, + ), ), - ), - const SizedBox(height: Grid.xxs), - Text( - '$biometricLabel to confirm it’s you before sending your Buzz identity to another device.', - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context._onboardingMutedInk, + const SizedBox(height: Grid.xs), + if (!fullScreen) + Text( + '$biometricLabel to confirm it’s you before sending your Buzz identity to another device.', + textAlign: TextAlign.center, + style: OnboardingTypography.subtitle.copyWith( + color: context._onboardingMutedInk, + ), ), - ), ] else if (codeAccepted.value) ...[ Icon(BuzzIcons.shieldCheck, size: 64, color: context._onboardingInk), const SizedBox(height: Grid.sm), - Text('Code confirmed', style: context.textTheme.headlineSmall), + if (!fullScreen) + Text('Code confirmed', style: OnboardingTypography.title), ] else ...[ - Text( - 'Enter pairing code', - textAlign: TextAlign.center, - style: context.textTheme.headlineSmall?.copyWith( - color: context._onboardingInk, - fontWeight: FontWeight.w600, - letterSpacing: -0.4, + if (!fullScreen) + Text( + 'Enter pairing code', + textAlign: TextAlign.center, + style: OnboardingTypography.title.copyWith( + color: context._onboardingInk, + ), ), - ), const SizedBox(height: Grid.md), _PairingErrorShake( revision: shakeRevision.value, @@ -248,6 +251,23 @@ class _SasVerificationView extends HookConsumerWidget { ), ); + if (fullScreen) { + return OnboardingStepPage( + title: showProtection + ? 'Protect your identity' + : codeAccepted.value + ? 'Code confirmed' + : 'Enter pairing code', + subtitle: showProtection + ? '$biometricLabel to confirm it’s you before sending your Buzz identity to another device.' + : codeAccepted.value + ? null + : 'Enter the six-digit code shown on your desktop.', + onBack: onDeny, + content: verificationContent, + actions: [verificationActions], + ); + } return Column( children: [ Expanded( diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index 56aeb4ab10..ac13b78d68 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -10,6 +10,7 @@ import 'features/onboarding/hosted_connect_page.dart'; import 'features/onboarding/self_hosted_entry_sheet.dart'; import 'shared/auth/auth.dart'; import 'shared/theme/grid.dart'; +import 'shared/theme/theme_extensions.dart'; import 'shared/deeplink/deep_link.dart'; import 'shared/hosted/hosted_community_connection.dart'; import 'shared/hosted/hosted_flow.dart'; @@ -59,13 +60,7 @@ class HostedAppGate extends HookConsumerWidget { (flow.accountOnly && flow.stage == HostedFlowStage.working && flow.account.account == null)); - final inlineAccountStatus = - flow != null && - (!authenticated || inlineEntry) && - flow.accountOnly && - (flow.stage == HostedFlowStage.working || - (flow.stage == HostedFlowStage.blocked && - !flow.canChangeCommunities)); + final fullScreenSetup = visible && !openingSignIn; return DeepLinkDispatcher( dispatchMessageLinks: authenticated && !visible, onboardingHandler: (routeContext, link) async { @@ -98,6 +93,7 @@ class HostedAppGate extends HookConsumerWidget { routeContext, relay: link.relayUrl, name: link.name, + fullScreen: inlineEntry, ); if (joined && context.mounted) { ref.read(communityEntryCompletionProvider.notifier).complete(); @@ -134,7 +130,14 @@ class HostedAppGate extends HookConsumerWidget { child: Stack( fit: StackFit.expand, children: [ - child, + ExcludeFocus( + excluding: fullScreenSetup, + child: ExcludeSemantics( + excluding: fullScreenSetup, + child: Offstage(offstage: fullScreenSetup, child: child), + ), + ), + if (fullScreenSetup) ColoredBox(color: context.colors.surface), if (flow != null) ...[ if (authenticated && !visible) HostedReconnectBanner(flow: flow), if (flow.stage == HostedFlowStage.signIn && @@ -167,21 +170,11 @@ class HostedAppGate extends HookConsumerWidget { ), ), ), - if (flow.stage == HostedFlowStage.profile) - ProfileSetupPage(flow: flow), - if (visible && - flow.stage != HostedFlowStage.profile && - !openingSignIn && - !inlineAccountStatus && - (!inlineEntry || - flow.stage == HostedFlowStage.chooseIdentity || - flow.stage == HostedFlowStage.setupIdentity || - flow.stage == HostedFlowStage.chooseCommunities || - flow.stage == HostedFlowStage.communityConflict || - flow.canChangeCommunities)) + if (fullScreenSetup) HostedConnectPage( flow: flow, onClose: () => unawaited(flow.leave()), + profileBuilder: (_) => ProfileSetupPage(flow: flow), ), ], ], diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index 0c8e8e0a61..f183442b90 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'features/pairing/pairing_page.dart'; -import 'features/onboarding/hosted_connect_sheet.dart'; import 'features/pairing/pairing_provider.dart'; import 'shared/hosted/hosted_flow_provider.dart'; import 'shared/hosted/hosted_flow.dart'; @@ -14,8 +13,15 @@ import 'shared/hosted/hosted_flow_problem_message.dart'; /// Navigation over the existing branded welcome surface. class OnboardingAppEntry extends HookConsumerWidget { - const OnboardingAppEntry({super.key, this.addingCommunity = false}); + const OnboardingAppEntry({ + super.key, + this.addingCommunity = false, + this.restoreAccount = true, + this.onExit, + }); final bool addingCommunity; + final bool restoreAccount; + final VoidCallback? onExit; @override Widget build(BuildContext context, WidgetRef ref) { @@ -27,7 +33,7 @@ class OnboardingAppEntry extends HookConsumerWidget { : null; useListenable(flow); useEffect(() { - if (flow == null) return null; + if (flow == null || !restoreAccount) return null; Future restore() async { await flow.initialize(); if (context.mounted) await flow.signInAccount(openBrowser: false); @@ -63,6 +69,7 @@ class OnboardingAppEntry extends HookConsumerWidget { scheduleMicrotask(() { presentation.setActive(false); if (addingCommunity && + onExit == null && flow != null && flow.stage != HostedFlowStage.complete) { unawaited(flow.leave()); @@ -70,20 +77,6 @@ class OnboardingAppEntry extends HookConsumerWidget { }); }; }, [flow]); - final inlineSetup = - flow != null && - !flow.accountOnly && - flow.stage != HostedFlowStage.signIn && - !(flow.stage == HostedFlowStage.working && - flow.account.account == null) && - flow.stage != HostedFlowStage.idle && - flow.stage != HostedFlowStage.complete && - flow.stage != HostedFlowStage.profile && - flow.stage != HostedFlowStage.chooseIdentity && - flow.stage != HostedFlowStage.setupIdentity && - flow.stage != HostedFlowStage.chooseCommunities && - flow.stage != HostedFlowStage.communityConflict && - !flow.canChangeCommunities; final signingIn = flow?.stage == HostedFlowStage.signIn; final busy = flow != null && @@ -103,7 +96,25 @@ class OnboardingAppEntry extends HookConsumerWidget { : connected ? 'Connected to Builderlab.' : null; + final handlingBack = useRef(false); void back() { + // Nested page and entry PopScopes receive the same system Back event. + if (handlingBack.value) return; + handlingBack.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + handlingBack.value = false; + }); + if (!existingUser.value && + !enteringCommunityLink.value && + !creating.value && + (onExit != null || addingCommunity)) { + if (onExit != null) { + onExit!(); + } else { + Navigator.of(context).pop(); + } + return; + } unawaited(flow?.leave()); existingUser.value = false; creating.value = false; @@ -114,16 +125,23 @@ class OnboardingAppEntry extends HookConsumerWidget { return PopScope( canPop: + onExit == null && !existingUser.value && !enteringCommunityLink.value && - !creating.value && - !inlineSetup, + !creating.value, onPopInvokedWithResult: (didPop, _) { + if (!didPop && + onExit != null && + !existingUser.value && + !enteringCommunityLink.value && + !creating.value) { + back(); + return; + } if (!didPop && (existingUser.value || creating.value || - enteringCommunityLink.value || - inlineSetup)) { + enteringCommunityLink.value)) { back(); } }, @@ -155,15 +173,6 @@ class OnboardingAppEntry extends HookConsumerWidget { accountStatus: status, builderlabBusy: busy, signInPending: signingIn || busy, - setupContent: flow != null && inlineSetup - ? Column( - mainAxisSize: MainAxisSize.min, - children: [ - HostedConnectSheet(flow: flow), - TextButton(onPressed: back, child: const Text('Leave setup')), - ], - ) - : null, builderlabLabel: signingIn ? 'Cancel sign-in' : busy diff --git a/mobile/lib/shared/community/community_grid.dart b/mobile/lib/shared/community/community_grid.dart new file mode 100644 index 0000000000..339d379551 --- /dev/null +++ b/mobile/lib/shared/community/community_grid.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import '../theme/theme.dart'; + +const communityGridAvatarSize = 88.0; + +/// Intrinsic-height rows keep two columns without clipping scaled labels. +class CommunityGrid extends StatelessWidget { + const CommunityGrid({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) => Column( + key: const Key('community-switcher-options'), + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < children.length; index += 2) + Padding( + padding: const EdgeInsets.only(bottom: Grid.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: children[index]), + const SizedBox(width: Grid.xs), + Expanded( + child: index + 1 < children.length + ? children[index + 1] + : const SizedBox.shrink(), + ), + ], + ), + ), + ], + ); +} diff --git a/mobile/lib/shared/community/community_grid_add.dart b/mobile/lib/shared/community/community_grid_add.dart new file mode 100644 index 0000000000..97cc7658ca --- /dev/null +++ b/mobile/lib/shared/community/community_grid_add.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import '../theme/theme.dart'; +import '../theme/buzz_icons.dart'; +import 'community_grid.dart'; + +/// Opens the Add Community flow from a community grid. +class CommunityGridAdd extends StatelessWidget { + const CommunityGridAdd({super.key, required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + child: InkWell( + key: const Key('community-switcher-add'), + borderRadius: BorderRadius.circular(Radii.card), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Column( + children: [ + ClipRSuperellipse( + borderRadius: BorderRadius.circular( + Radii.card * communityGridAvatarSize / 40, + ), + child: Container( + width: communityGridAvatarSize, + height: communityGridAvatarSize, + color: context.controlSurface.withValues(alpha: 0.5), + child: Icon( + BuzzIcons.plus, + size: 32, + color: context.colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: Grid.xs), + Text( + 'Add Community', + textAlign: TextAlign.center, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); +} diff --git a/mobile/lib/shared/community/community_grid_option.dart b/mobile/lib/shared/community/community_grid_option.dart new file mode 100644 index 0000000000..3b2d7b0468 --- /dev/null +++ b/mobile/lib/shared/community/community_grid_option.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import '../theme/theme.dart'; +import 'community_avatar.dart'; + +import 'community_grid.dart'; + +/// A community tile shared by onboarding and the switcher. +class CommunityGridOption extends HookWidget { + const CommunityGridOption({ + super.key, + required this.id, + required this.name, + required this.relayUrl, + required this.onSelect, + this.isActive = false, + this.hidden = false, + this.enabled = true, + this.badge, + }); + final String id; + final String name; + final String relayUrl; + final Future Function(Rect) onSelect; + final bool isActive; + final bool hidden; + final bool enabled; + final Widget? badge; + @override + Widget build(BuildContext context) { + final avatarKey = useMemoized(GlobalKey.new); + return Semantics( + selected: isActive, + button: enabled, + child: InkWell( + key: Key('community-switcher-row-$id'), + borderRadius: BorderRadius.circular(Radii.card), + onTap: !enabled + ? null + : () { + final box = avatarKey.currentContext?.findRenderObject(); + if (box is RenderBox) { + unawaited( + onSelect(box.localToGlobal(Offset.zero) & box.size), + ); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Opacity( + opacity: hidden ? 0 : 1, + child: SizedBox( + key: avatarKey, + child: ExcludeSemantics( + child: CommunityAvatar( + key: Key('community-switcher-avatar-$id'), + name: name, + relayUrl: relayUrl, + size: communityGridAvatarSize, + ), + ), + ), + ), + if (badge != null) + Positioned( + right: -Grid.twelve, + bottom: -Grid.twelve, + child: badge!, + ), + ], + ), + const SizedBox(height: Grid.xs), + Text( + name, + textAlign: TextAlign.center, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index d4b63e2e25..878a10ee64 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -77,6 +77,7 @@ class HostedFlow extends ChangeNotifier { required this.store, required this.identity, required this.enterCommunity, + this.openLandingCommunity, Future Function(Uri)? openWeb, }) : _openWeb = openWeb ?? @@ -86,6 +87,7 @@ class HostedFlow extends ChangeNotifier { final AccountIdentityStore store; final HostedIdentityService identity; final HostedCommunityAdmission enterCommunity; + final HostedCommunityAdmission? openLandingCommunity; bool get canRetry => problem == null || problem == HostedFlowProblem.retry || @@ -103,15 +105,23 @@ class HostedFlow extends ChangeNotifier { SensitiveActionPolicy? pairedSensitiveActionPolicy; List communities = const []; Set selectedRelays = const {}; + String? landingRelay; final Set _addedRelays = {}; bool _addingCommunities = false; + Completer? _pendingAdd; String? _batchSubject; String? _conflictingRelay; + String? _failedRelay; + bool get canSkipFailedCommunity => + canChangeCommunities && + stage == HostedFlowStage.blocked && + _failedRelay != null; bool get canChangeCommunities => accountOnly && account.account?.subject == _batchSubject && (stage == HostedFlowStage.blocked || - stage == HostedFlowStage.communityConflict) && + stage == HostedFlowStage.communityConflict || + (stage == HostedFlowStage.working && _addingCommunities)) && communities.isNotEmpty; String? addingCommunityName; int get addedCommunityCount => _addedRelays.length; @@ -122,6 +132,7 @@ class HostedFlow extends ChangeNotifier { int _generation = 0; int _nextGeneration() { + _failedRelay = null; _cancelPolicy(); _cancelProfile(); return ++_generation; @@ -633,18 +644,33 @@ class HostedFlow extends ChangeNotifier { _set(HostedFlowStage.complete); } - void selectCommunity(String relay, bool selected) { + void chooseLandingCommunity(String relay) { if (stage != HostedFlowStage.chooseCommunities || !communities.any((item) => item.relayUrl == relay)) { return; } - selectedRelays = Set.unmodifiable({ - ...selectedRelays.where((item) => item != relay), - if (selected) relay, - }); + selectedRelays = Set.unmodifiable({...selectedRelays, relay}); + landingRelay = relay; notifyListeners(); } + /// Restore existing memberships before presenting a separate Add route. + Future prepareToAddCommunity() { + if (_pendingAdd != null || stage != HostedFlowStage.chooseCommunities) { + return Future.value(false); + } + if (selectedRelays.isEmpty) return Future.value(true); + final pending = _pendingAdd = Completer(); + unawaited(addSelectedCommunities()); + return pending.future; + } + + void _finishPreparingAdd(bool ready) { + final pending = _pendingAdd; + _pendingAdd = null; + pending?.complete(ready); + } + Future addSelectedCommunities() { if (!accountOnly || stage != HostedFlowStage.chooseCommunities || @@ -657,19 +683,29 @@ class HostedFlow extends ChangeNotifier { return _enqueue(() => _continue(generation)); } - /// Keep completed additions and let the user omit a failing target. + /// Keep completed additions while allowing a different landing choice. void backToCommunityChoices() { if (!canChangeCommunities) return; + _finishPreparingAdd(false); _nextGeneration(); - communities = List.unmodifiable( - communities.where((item) => !_addedRelays.contains(item.relayUrl)), - ); - selectedRelays = Set.unmodifiable(selectedRelays.difference(_addedRelays)); _conflictingRelay = null; addingCommunityName = null; _set(HostedFlowStage.chooseCommunities); } + /// Omit only the failed membership for this restore attempt. + Future skipFailedCommunity() { + if (!canSkipFailedCommunity) return Future.value(); + final relay = _failedRelay!; + selectedRelays = Set.unmodifiable( + selectedRelays.where((item) => item != relay), + ); + _failedRelay = null; + final generation = _nextGeneration(); + _set(HostedFlowStage.working); + return _enqueue(() => _continue(generation)); + } + Future skipConflictingCommunity() { final relay = _conflictingRelay; if (stage != HostedFlowStage.communityConflict || relay == null) { @@ -702,11 +738,17 @@ class HostedFlow extends ChangeNotifier { await leave(noCommunities: true); return; } - // Direct account entry imports every joined community. The existing - // switcher handles choosing which community to enter after admission. - _addingCommunities = true; + landingRelay = listed.first.relayUrl; + // Choose where to land; every membership is restored. + _set(HostedFlowStage.chooseCommunities); + return; } - for (final community in communities) { + _failedRelay = null; + final ordered = [ + ...communities.where((item) => item.relayUrl == landingRelay), + ...communities.where((item) => item.relayUrl != landingRelay), + ]; + for (final community in ordered) { if (!current()) return; if (!selectedRelays.contains(community.relayUrl) || _addedRelays.contains(community.relayUrl)) { @@ -721,12 +763,20 @@ class HostedFlow extends ChangeNotifier { _conflictingRelay = community.relayUrl; _set(HostedFlowStage.communityConflict); return; + } catch (_) { + if (current()) _failedRelay = community.relayUrl; + rethrow; } if (!current()) return; _addedRelays.add(community.relayUrl); } if (!current()) return; addingCommunityName = null; + if (_pendingAdd != null) { + _set(HostedFlowStage.chooseCommunities); + _finishPreparingAdd(true); + return; + } if (_addedRelays.isEmpty) { communities = List.unmodifiable( communities.where((item) => selectedRelays.contains(item.relayUrl)), @@ -734,6 +784,19 @@ class HostedFlow extends ChangeNotifier { await leave(noCommunities: true); return; } + final destination = _addedRelays.contains(landingRelay) + ? landingRelay! + : _addedRelays.first; + await openLandingCommunity?.call( + AddCommunityDeepLink( + relayUrl: destination, + name: Uri.parse(destination).host, + ), + subject, + pubkey, + current, + ); + if (!current()) return; await browser.clearIntent(); if (current()) _set(HostedFlowStage.complete); } @@ -835,9 +898,12 @@ class HostedFlow extends ChangeNotifier { bool noCommunitiesToAdd = false; void _clearCommunityBatch() { + _finishPreparingAdd(false); noCommunitiesToAdd = false; communities = const []; selectedRelays = const {}; + landingRelay = null; + _failedRelay = null; _addedRelays.clear(); _batchSubject = null; _conflictingRelay = null; @@ -909,6 +975,7 @@ class HostedFlow extends ChangeNotifier { @override void dispose() { _disposed = true; + _finishPreparingAdd(false); _cancelPolicy(); _cancelProfile(); _nextGeneration(); diff --git a/mobile/lib/shared/hosted/hosted_flow_provider.dart b/mobile/lib/shared/hosted/hosted_flow_provider.dart index ff9d4c1001..d5c5e1994d 100644 --- a/mobile/lib/shared/hosted/hosted_flow_provider.dart +++ b/mobile/lib/shared/hosted/hosted_flow_provider.dart @@ -5,7 +5,6 @@ import '../../features/profile/profile_provider.dart'; import '../relay/relay_provider.dart'; import '../relay/relay_session.dart'; import '../auth/auth.dart'; -import '../community/paired_community_landing.dart'; import '../deeplink/deep_link.dart'; import 'account_identity_store.dart'; import 'browser_auth.dart'; @@ -69,6 +68,25 @@ HostedFlow createHostedFlow( browser: browser, store: store, identity: HostedIdentityService(account: account, store: store), + openLandingCommunity: (link, subject, pubkey, isCurrent) async { + bool current() => isCurrent() && account.account?.subject == subject; + final storage = ref.read(communityStorageProvider); + final saved = await storage.loadAll(); + if (!current()) return; + final destination = saved + .where( + (item) => + relayIdentity(item.relayUrl) == relayIdentity(link.relayUrl!) && + item.pubkey == pubkey, + ) + .firstOrNull; + if (destination == null) throw AccountFailure.unavailable; + final active = await storage.loadActiveId(); + if (!current() || active == destination.id) return; + await ref + .read(authProvider.notifier) + .authenticateWithCommunity(destination, shouldContinue: current); + }, enterCommunity: (link, subject, pubkey, isCurrent) async { await connectHostedCommunity( link: link, @@ -107,8 +125,6 @@ HostedFlow createHostedFlow( void onCompleted() { if (flow.reconnecting && flow.stage == HostedFlowStage.complete) { unawaited(ref.read(relaySessionProvider.notifier).reconnect()); - } else if (flow.accountOnly && flow.stage == HostedFlowStage.complete) { - ref.read(communityGridLandingProvider.notifier).request(); } } diff --git a/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart b/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart index 40ddb3317c..10c91eedb1 100644 --- a/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart +++ b/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart @@ -7,6 +7,7 @@ import '../hosted/account_identity_store.dart'; import '../theme/theme.dart'; import '../utils/string_utils.dart'; import '../widgets/modal_presentation.dart'; +import '../widgets/onboarding_step_page.dart'; import 'nsec_transfer.dart'; /// Lets the user inspect the public identity before explicitly importing it. @@ -33,6 +34,8 @@ class NsecImportSheet extends HookWidget { this.pickFile, this.mismatchMessage, this.autofocus = true, + this.fullScreen = false, + this.onBack, }); final Future Function(String nsec) onImport; @@ -41,6 +44,8 @@ class NsecImportSheet extends HookWidget { /// Allows a containing sheet to finish expanding before opening the keyboard. final bool autofocus; + final bool fullScreen; + final VoidCallback? onBack; @override Widget build(BuildContext context) { @@ -58,22 +63,23 @@ class NsecImportSheet extends HookWidget { final publicKey = useState(null); final error = useState(null); final busy = useState(false); + final cancelled = useRef(false); Future chooseFile() async { busy.value = true; error.value = null; try { final text = await (pickFile ?? NsecTransfer.pickFile)(); - if (!context.mounted || text == null) return; + if (!context.mounted || cancelled.value || text == null) return; final preview = NsecTransfer.preview(text); input.text = text.trim(); publicKey.value = preview; } catch (_) { - if (context.mounted) { + if (context.mounted && !cancelled.value) { error.value = 'Choose a text file containing one valid nsec.'; } } finally { - if (context.mounted) busy.value = false; + if (context.mounted && !cancelled.value) busy.value = false; } } @@ -87,11 +93,11 @@ class NsecImportSheet extends HookWidget { } busy.value = true; await onImport(input.text.trim()); - if (!context.mounted) return; + if (!context.mounted || cancelled.value) return; input.clear(); Navigator.of(context).pop(true); } catch (failure) { - if (!context.mounted) return; + if (!context.mounted || cancelled.value) return; error.value = switch (failure) { AccountIdentityFailure.mismatch => mismatchMessage ?? @@ -104,10 +110,103 @@ class NsecImportSheet extends HookWidget { 'Could not finish importing this identity. Try again with the same key.', }; } finally { - if (context.mounted) busy.value = false; + if (context.mounted && !cancelled.value) busy.value = false; } } + final children = [ + if (defaultTargetPlatform == TargetPlatform.iOS) + CupertinoTextField( + controller: input, + placeholder: 'Private key (nsec)', + padding: const EdgeInsets.all(Grid.gutter), + decoration: BoxDecoration( + color: CupertinoColors.secondarySystemGroupedBackground.resolveFrom( + context, + ), + borderRadius: BorderRadius.circular(Radii.container), + ), + focusNode: focus, + maxLength: 1024, + enabled: !busy.value, + obscureText: true, + autocorrect: false, + enableSuggestions: false, + enableIMEPersonalizedLearning: false, + onChanged: (_) { + publicKey.value = null; + error.value = null; + }, + ) + else + TextField( + controller: input, + maxLength: 1024, + enabled: !busy.value, + obscureText: true, + autocorrect: false, + enableSuggestions: false, + enableIMEPersonalizedLearning: false, + decoration: const InputDecoration(labelText: 'Private key (nsec)'), + onChanged: (_) { + publicKey.value = null; + error.value = null; + }, + ), + const SizedBox(height: Grid.xs), + OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ), + onPressed: busy.value ? null : chooseFile, + child: const Text('Choose nsec file'), + ), + if (publicKey.value != null) ...[ + const Text('Confirm this is the public identity you want to use:'), + const SizedBox(height: Grid.xs), + SelectableText(fullNpub(publicKey.value!)!), + const SizedBox(height: Grid.sm), + ], + if (error.value != null) + Text(error.value!, style: TextStyle(color: context.colors.error)), + const SizedBox(height: Grid.xs), + FilledButton( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ), + onPressed: busy.value ? null : submit, + child: Text( + busy.value + ? 'Importing…' + : publicKey.value == null + ? 'Preview identity' + : 'Import this identity', + ), + ), + ]; + if (fullScreen) { + return OnboardingStepPage( + title: 'Import identity', + subtitle: + 'Bring your Buzz identity from another device. Check the public identity before continuing.', + onBack: () { + if (cancelled.value || (onBack == null && busy.value)) return; + cancelled.value = true; + if (onBack != null) { + onBack!(); + } else { + Navigator.of(context).pop(false); + } + }, + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children.sublist(0, children.length - 1), + ), + actions: [children.last], + ); + } return SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( @@ -119,81 +218,7 @@ class NsecImportSheet extends HookWidget { child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (defaultTargetPlatform == TargetPlatform.iOS) - CupertinoTextField( - controller: input, - placeholder: 'Private key (nsec)', - padding: const EdgeInsets.all(Grid.gutter), - decoration: BoxDecoration( - color: CupertinoColors.secondarySystemGroupedBackground - .resolveFrom(context), - borderRadius: BorderRadius.circular(Radii.container), - ), - focusNode: focus, - maxLength: 1024, - enabled: !busy.value, - obscureText: true, - autocorrect: false, - enableSuggestions: false, - enableIMEPersonalizedLearning: false, - onChanged: (_) { - publicKey.value = null; - error.value = null; - }, - ) - else - TextField( - controller: input, - maxLength: 1024, - enabled: !busy.value, - obscureText: true, - autocorrect: false, - enableSuggestions: false, - enableIMEPersonalizedLearning: false, - decoration: const InputDecoration( - labelText: 'Private key (nsec)', - ), - onChanged: (_) { - publicKey.value = null; - error.value = null; - }, - ), - const SizedBox(height: Grid.xs), - OutlinedButton( - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(52), - shape: const StadiumBorder(), - ), - onPressed: busy.value ? null : chooseFile, - child: const Text('Choose nsec file'), - ), - if (publicKey.value != null) ...[ - const Text( - 'Confirm this is the public identity you want to use:', - ), - const SizedBox(height: Grid.xs), - SelectableText(fullNpub(publicKey.value!)!), - const SizedBox(height: Grid.sm), - ], - if (error.value != null) - Text(error.value!, style: TextStyle(color: context.colors.error)), - const SizedBox(height: Grid.xs), - FilledButton( - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(52), - shape: const StadiumBorder(), - ), - onPressed: busy.value ? null : submit, - child: Text( - busy.value - ? 'Importing…' - : publicKey.value == null - ? 'Preview identity' - : 'Import this identity', - ), - ), - ], + children: children, ), ), ); diff --git a/mobile/lib/shared/theme/onboarding_typography.dart b/mobile/lib/shared/theme/onboarding_typography.dart new file mode 100644 index 0000000000..5a7639162e --- /dev/null +++ b/mobile/lib/shared/theme/onboarding_typography.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +/// Shared setup-page typography, using the approved Inter treatment. +abstract final class OnboardingTypography { + static const title = TextStyle( + fontFamily: 'Inter', + fontSize: 30, + height: 36 / 30, + fontWeight: FontWeight.w400, + letterSpacing: -1, + ); + static const subtitle = TextStyle( + fontFamily: 'Inter', + fontSize: 16, + height: 24 / 16, + fontWeight: FontWeight.w400, + letterSpacing: -0.25, + ); +} diff --git a/mobile/lib/shared/theme/theme.dart b/mobile/lib/shared/theme/theme.dart index 41140159d0..a9590ac419 100644 --- a/mobile/lib/shared/theme/theme.dart +++ b/mobile/lib/shared/theme/theme.dart @@ -19,3 +19,4 @@ export 'text_theme.dart' show bodyExtraSmallTextStyle; export 'utility_surface_theme.dart'; export 'mobile_appearance.dart'; export 'chat_colors.dart'; +export 'onboarding_typography.dart'; diff --git a/mobile/lib/shared/widgets/bee_loading_mark.dart b/mobile/lib/shared/widgets/bee_loading_mark.dart new file mode 100644 index 0000000000..984203aa1f --- /dev/null +++ b/mobile/lib/shared/widgets/bee_loading_mark.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../theme/theme_extensions.dart'; + +/// Crisp mobile rendering of desktop BuzzLogoAnimation's v8 loading morph. +/// Uses its 880ms keyframes and easing, without the desktop noise filter. +class BeeLoadingMark extends HookWidget { + const BeeLoadingMark({super.key}); + + @override + Widget build(BuildContext context) { + final reduced = MediaQuery.disableAnimationsOf(context); + final controller = useAnimationController( + duration: const Duration(milliseconds: 880), + ); + useEffect(() { + if (reduced) { + controller + ..stop() + ..value = 1; + } else { + controller.repeat(reverse: true); + } + return controller.stop; + }, [controller, reduced]); + return Semantics( + label: 'Loading', + liveRegion: true, + child: ExcludeSemantics( + child: RepaintBoundary( + child: CustomPaint( + size: const Size(80, 80 * 309 / 466), + painter: _BeeMorphPainter(controller, context.colors.onSurface), + ), + ), + ), + ); + } +} + +class _BeeMorphPainter extends CustomPainter { + _BeeMorphPainter(this.animation, this.color) : super(repaint: animation); + final Animation animation; + final Color color; + static const ease = Cubic(.16, 1, .3, 1); + + double value(List times, List values) { + final t = animation.value; + for (var i = 1; i < times.length; i++) { + if (t <= times[i]) { + final progress = ((t - times[i - 1]) / (times[i] - times[i - 1])).clamp( + 0.0, + 1.0, + ); + return values[i - 1] + + (values[i] - values[i - 1]) * ease.transform(progress); + } + } + return values.last; + } + + @override + void paint(Canvas canvas, Size size) { + canvas.save(); + canvas.scale(size.width / 466); + canvas.saveLayer(const Rect.fromLTWH(-20, -20, 506, 349), Paint()); + final wingOpacity = value([0, .54, .55, 1], [0, 0, 1, 1]); + final wingPaint = Paint() + ..color = color.withValues(alpha: color.a * wingOpacity); + for (final xs in const >[ + [233.0, 233, 76, 95, 91.7], + [233.0, 233, 390, 371, 374.3], + ]) { + canvas.drawCircle( + Offset(value([0, .54, .74, .9, 1], xs), 154.5), + 91.7, + wingPaint, + ); + } + const bodyTimes = [0.0, .08, .16, .23, .35, .47, .6, 1.0]; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + value(bodyTimes, [233.4, 200, 186, 186, 120, 132, 127, 128]), + value(bodyTimes, [154.5, 121, 108, 108, 42, -12, 3, 0]), + value(bodyTimes, [0, 66, 93, 93, 226, 204, 212, 210]), + value(bodyTimes, [0, 66, 93, 93, 226, 326, 304, 309]), + ), + Radius.circular(value(bodyTimes, [0, 33, 46.5, 14, 40, 37, 33, 34])), + ), + Paint() + ..color = color.withValues( + alpha: color.a * value([0, .03, .08, 1], [0, 0, 1, 1]), + ), + ); + final eyePaint = Paint() + ..blendMode = BlendMode.dstOut + ..color = Colors.black.withValues( + alpha: value([0, .18, .2, 1], [0, 0, 1, 1]), + ); + for (final xs in const >[ + [233.4, 233.4, 185, 196, 193.3, 193.3], + [233.4, 233.4, 283, 273, 276, 276], + ]) { + canvas.drawOval( + Rect.fromCenter( + center: Offset( + value([0, .23, .45, .58, .68, 1], xs), + value( + [0, .23, .45, .58, .68, 1], + [154.5, 154.5, 76, 87, 84.4, 84.4], + ), + ), + width: 54, + height: 2 * value([0, .76, .84, .92, 1], [27, 27, 2, 27, 27]), + ), + eyePaint, + ); + } + for (final lower in [false, true]) { + final times = lower + ? [0.0, .54, .72, .84, .94, 1.0] + : [0.0, .42, .6, .72, .82, 1.0]; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + value(times, [ + 234.8, + 234.8, + 158, + 169, + lower ? 166.9 : 166.3, + lower ? 166.9 : 166.3, + ]), + lower ? 235.1 : 157.2, + value(times, [ + 0, + 0, + 153, + 132, + lower ? 136.2 : 136.9, + lower ? 136.2 : 136.9, + ]), + lower ? 37.6 : 38.3, + ), + const Radius.circular(5), + ), + Paint() + ..blendMode = BlendMode.dstOut + ..color = Colors.black.withValues( + alpha: value(lower ? [0, .54, .55, 1] : [0, .42, .43, 1], [ + 0, + 0, + 1, + 1, + ]), + ), + ); + } + canvas.restore(); + canvas.restore(); + } + + @override + bool shouldRepaint(_BeeMorphPainter oldDelegate) => + oldDelegate.color != color || oldDelegate.animation != animation; +} diff --git a/mobile/lib/shared/widgets/ios_community_link_input.dart b/mobile/lib/shared/widgets/ios_community_link_input.dart new file mode 100644 index 0000000000..59fd7aa2a8 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_community_link_input.dart @@ -0,0 +1,48 @@ +import 'package:flutter/cupertino.dart'; + +import '../theme/theme.dart'; + +/// Borderless community address field used by both landing and Add entry. +class IosCommunityLinkInput extends StatelessWidget { + const IosCommunityLinkInput({ + super.key, + required this.controller, + this.focusNode, + this.autofocus = false, + this.enabled = true, + this.onChanged, + this.onSubmitted, + }); + + final TextEditingController controller; + final FocusNode? focusNode; + final bool autofocus; + final bool enabled; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + + @override + Widget build(BuildContext context) => CupertinoTextField( + key: const Key('community-link-input'), + controller: controller, + focusNode: focusNode, + autofocus: autofocus, + enabled: enabled, + placeholder: 'Community link or address', + placeholderStyle: context.textTheme.titleLarge?.copyWith( + color: CupertinoColors.placeholderText.resolveFrom(context), + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.go, + autocorrect: false, + enableSuggestions: false, + padding: const EdgeInsets.symmetric(vertical: Grid.xs), + style: context.textTheme.titleLarge?.copyWith( + color: context.colors.onSurface, + ), + cursorColor: context.colors.primary, + decoration: null, + onChanged: onChanged, + onSubmitted: onSubmitted, + ); +} diff --git a/mobile/lib/shared/widgets/onboarding_loading_page.dart b/mobile/lib/shared/widgets/onboarding_loading_page.dart new file mode 100644 index 0000000000..aa0797001e --- /dev/null +++ b/mobile/lib/shared/widgets/onboarding_loading_page.dart @@ -0,0 +1,80 @@ +import 'dart:math' show max; + +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; +import 'bee_loading_mark.dart'; + +/// Centers the loading mark and heading together as one block. +class OnboardingLoadingPage extends StatelessWidget { + const OnboardingLoadingPage({super.key, required this.title, this.onCancel}); + final String title; + final VoidCallback? onCancel; + + @override + Widget build(BuildContext context) { + final theme = utilitySurfaceThemeData(Theme.of(context)); + final safe = MediaQuery.paddingOf(context); + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) onCancel?.call(); + }, + child: Theme( + data: theme, + child: Scaffold( + backgroundColor: theme.colorScheme.surface, + body: Stack( + children: [ + Positioned.fill( + child: LayoutBuilder( + builder: (context, bounds) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: bounds.maxHeight), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: Grid.sm, + vertical: max(safe.top, safe.bottom) + Grid.sm, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Center(child: BeeLoadingMark()), + const SizedBox(height: Grid.sm), + Semantics( + header: true, + namesRoute: true, + child: Text( + title, + textAlign: TextAlign.center, + style: OnboardingTypography.title.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + if (onCancel != null) + Positioned( + left: Grid.sm, + right: Grid.sm, + bottom: safe.bottom + Grid.sm, + child: TextButton( + onPressed: onCancel, + child: const Text('Cancel'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/onboarding_step_page.dart b/mobile/lib/shared/widgets/onboarding_step_page.dart new file mode 100644 index 0000000000..c4fcd668b9 --- /dev/null +++ b/mobile/lib/shared/widgets/onboarding_step_page.dart @@ -0,0 +1,214 @@ +import 'dart:ui' show ImageFilter; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/semantics.dart'; + +import '../theme/theme.dart'; +import 'ios_navigation_bar.dart'; + +/// Opaque onboarding chrome shared by account setup and identity import. +/// A scrollable minimum-height body keeps actions at the bottom without +/// clipping content when the keyboard or larger accessibility text is shown. +class OnboardingStepPage extends StatelessWidget { + const OnboardingStepPage({ + super.key, + required this.title, + this.subtitle, + this.onConnectionOptions, + this.foregroundProgress = 0, + this.keyboardBottomSpacing = Grid.sm, + required this.onBack, + required this.content, + this.actions = const [], + }); + + final String title; + final String? subtitle; + final VoidCallback? onConnectionOptions; + final double foregroundProgress; + final double keyboardBottomSpacing; + final VoidCallback onBack; + final Widget content; + final List actions; + + @override + Widget build(BuildContext context) { + final theme = utilitySurfaceThemeData(Theme.of(context)); + final ios = defaultTargetPlatform == TargetPlatform.iOS; + final buttonStyle = FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ); + return Theme( + data: theme.copyWith( + filledButtonTheme: FilledButtonThemeData(style: buttonStyle), + outlinedButtonTheme: OutlinedButtonThemeData(style: buttonStyle), + textButtonTheme: TextButtonThemeData(style: buttonStyle), + ), + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) onBack(); + }, + child: Scaffold( + backgroundColor: theme.colorScheme.surface, + body: SafeArea( + top: !ios, + child: ExcludeFocus( + excluding: foregroundProgress > 0, + child: ExcludeSemantics( + excluding: foregroundProgress > 0, + child: IgnorePointer( + ignoring: foregroundProgress > 0, + child: Opacity( + key: const Key('scanner-welcome-opacity'), + opacity: 1 - foregroundProgress, + child: Transform.scale( + key: const Key('scanner-welcome-scale'), + scale: MediaQuery.disableAnimationsOf(context) + ? 1 + : 1 - .04 * foregroundProgress, + child: ImageFiltered( + enabled: + !MediaQuery.disableAnimationsOf(context) && + foregroundProgress > 0 && + foregroundProgress < 1, + imageFilter: ImageFilter.blur( + sigmaX: 16 * foregroundProgress, + sigmaY: 16 * foregroundProgress, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (ios) + SizedBox( + height: + MediaQuery.paddingOf(context).top + + IosNavigationMetrics.of( + context, + ).compactHeight, + child: IosNavigationBar( + title: '', + leading: IosNavigationAction( + label: 'Back', + symbol: 'chevron.backward', + onPressed: onBack, + ), + ), + ) + else + Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + ), + child: IconButton( + tooltip: 'Back', + onPressed: onBack, + icon: const BackButtonIcon(), + ), + ), + ), + Expanded( + child: CustomScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.sm, + Grid.sm, + Grid.sm, + MediaQuery.viewInsetsOf( + context, + ).bottom > + 0 + ? keyboardBottomSpacing + : Grid.sm, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + GestureDetector( + key: onConnectionOptions == null + ? null + : const Key( + 'developer-pairing-trigger', + ), + excludeFromSemantics: true, + child: Semantics( + header: true, + namesRoute: true, + customSemanticsActions: + onConnectionOptions == null + ? null + : { + const CustomSemanticsAction( + label: + 'More connection options', + ): onConnectionOptions!, + }, + child: Text( + title, + style: OnboardingTypography + .title + .copyWith( + color: theme + .colorScheme + .onSurface, + ), + ), + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: Grid.xs), + Text( + subtitle!, + style: OnboardingTypography + .subtitle + .copyWith( + color: theme + .colorScheme + .onSurfaceVariant, + ), + ), + ], + const SizedBox(height: Grid.xs), + content, + const Spacer(), + if (actions.isNotEmpty) + const SizedBox(height: Grid.sm), + for ( + var i = 0; + i < actions.length; + i++ + ) ...[ + if (i > 0) + const SizedBox(height: Grid.xxs), + actions[i], + ], + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 743395eefb..c36c0f2e8a 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -2721,7 +2721,7 @@ void main() { expect(find.byType(FallbackPairingQrScanner), findsNothing); expect(find.text('Scan a QR code'), findsOneWidget); expect(find.text('Use pairing code'), findsNothing); - expect(find.byKey(const Key('developer-pairing-trigger')), findsOneWidget); + expect(find.text('Use a pairing code'), findsNothing); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); diff --git a/mobile/test/features/onboarding/fullscreen_onboarding_test.dart b/mobile/test/features/onboarding/fullscreen_onboarding_test.dart new file mode 100644 index 0000000000..2f52c4755e --- /dev/null +++ b/mobile/test/features/onboarding/fullscreen_onboarding_test.dart @@ -0,0 +1,340 @@ +import '../../helpers/two_finger_double_tap.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/hosted_community_connection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/features/pairing/add_community_page.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:buzz/shared/community/community_grid_option.dart'; +import 'dart:async'; +import 'package:buzz/onboarding_app_entry.dart'; + +import 'package:buzz/hosted_app_gate.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; +import 'package:buzz/shared/hosted/hosted_flow_provider.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/widgets/onboarding_step_page.dart'; +import 'package:buzz/shared/widgets/bee_loading_mark.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/hosted_communities_fixture.dart'; +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('Add uses landing input and leaves room above the keyboard', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(393, 852); + addTearDown(tester.view.reset); + for (final reduced in [false, true]) { + var pairingStarted = false; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: reduced), + child: child!, + ), + home: HookBuilder( + builder: (context) { + final entering = useState(false); + return AddCommunityPage( + enteringCommunityLink: entering.value, + onBack: () => entering.value = false, + onCommunityLink: () => entering.value = true, + onPairing: () => pairingStarted = true, + onScan: () {}, + onOpenCommunity: (_) {}, + onSubmitPairing: (_) {}, + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Use a pairing code'), findsNothing); + final firstTap = await tester.createGesture(); + await firstTap.down( + const Offset(196, 400), + timeStamp: const Duration(milliseconds: 100), + ); + await firstTap.up(timeStamp: const Duration(milliseconds: 120)); + await tester.pump(const Duration(milliseconds: 50)); + final secondTap = await tester.createGesture(); + await secondTap.down( + const Offset(196, 400), + timeStamp: const Duration(milliseconds: 200), + ); + await secondTap.up(timeStamp: const Duration(milliseconds: 220)); + await tester.pumpAndSettle(); + expect(find.text('Use a pairing code'), findsNothing); + await twoFingerDoubleTap(tester, const Offset(196, 400)); + expect(find.text('Use a pairing code'), findsOneWidget); + await twoFingerDoubleTap(tester, const Offset(196, 400)); + expect(find.text('Use a pairing code'), findsNothing); + await twoFingerDoubleTap(tester, const Offset(196, 400)); + expect(pairingStarted, isFalse); + expect(find.text('Use a pairing code').hitTestable(), findsOneWidget); + expect( + tester.getTopLeft(find.text('Use a pairing code')).dy, + greaterThan( + tester.getBottomLeft(find.text('Enter a community link')).dy, + ), + ); + await tester.tap(find.text('Use a pairing code')); + expect(pairingStarted, isTrue); + final action = find.byKey(const Key('add-community-link-action')); + final button = tester.element(action); + await tester.tap(find.text('Enter a community link')); + await tester.pump(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); + expect(tester.element(action), same(button)); + final input = tester.widget( + find.byKey(const Key('community-link-input')), + ); + expect(input.decoration, isNull); + expect(input.focusNode!.hasFocus, isTrue); + expect(852 - 300 - tester.getBottomLeft(action).dy, closeTo(12, 0.1)); + await tester.enterText( + find.byKey(const Key('community-link-input')), + 'example.com', + ); + await tester.binding.handlePopRoute(); + tester.view.viewInsets = const FakeViewPadding(); + await tester.pumpAndSettle(); + expect(find.text('Add a community'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('Home stays hidden until community choice and admission', ( + tester, + ) async { + addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue); + // Secure-storage mocks must remain in one widget-test zone. + for (final reduced in [false, true]) { + tester.platformDispatcher.accessibilityFeaturesTestValue = + FakeAccessibilityFeatures(disableAnimations: reduced); + FlutterSecureStorage.setMockInitialValues({}); + PendingDeepLinkNotifier.debugUriStreamOverride = + const Stream.empty(); + final fixture = HostedCommunitiesFixture(); + await fixture.initialize(widget: true); + fixture.rows = [fixture.row('one'), fixture.row('two')]; + + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: reduced, + overrides: [hostedFlowProvider.overrideWithValue(fixture.flow)], + child: const HostedAppGate( + authenticated: true, + child: Scaffold(body: Text('Home content')), + ), + ), + ); + await tester.pumpAndSettle(); + await fixture.flow.signInAccount(); + await tester.pumpAndSettle(); + expect(fixture.flow.stage, HostedFlowStage.chooseCommunities); + expect(fixture.admitted, isEmpty); + expect(find.text('Home content'), findsNothing); + expect(find.text('Select a community'), findsOneWidget); + expect(find.byType(BottomSheet), findsNothing); + expect(find.byTooltip('Back'), findsOneWidget); + // Cancelling restore must not open Add after the pending request returns. + final cancelledRestore = Completer(); + fixture.beforeAdmission = (_) => cancelledRestore.future; + await tester.ensureVisible( + find.byKey(const Key('community-switcher-add')), + ); + await tester.tap(find.byKey(const Key('community-switcher-add'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byTooltip('Back'), findsNothing); + expect(find.byType(BeeLoadingMark), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pump(); + expect(fixture.flow.stage, HostedFlowStage.chooseCommunities); + cancelledRestore.complete(); + await tester.pumpAndSettle(); + expect(find.byType(OnboardingAppEntry), findsNothing); + expect(find.text('Select a community'), findsOneWidget); + for (final conflict in [false, true]) { + fixture.beforeAdmission = (name) async { + if (name == 'two.communities.buzz.xyz') { + if (conflict) throw const SavedCommunityIdentityConflict(); + throw AccountFailure.unavailable; + } + }; + await tester.tap(find.byKey(const Key('community-switcher-add'))); + await tester.pumpAndSettle(); + expect(find.byType(OnboardingAppEntry), findsNothing); + await tester.tap( + find.text( + conflict ? 'Keep saved identity and skip' : 'Skip this community', + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(OnboardingAppEntry), findsOneWidget); + expect(fixture.openedCommunity, isNull); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.text('Select a community'), findsOneWidget); + fixture.admitted.clear(); + } + fixture.beforeAdmission = null; + // Add restores existing memberships, stays opaque, and returns to the + // picker on Back without the disposed Add route cancelling discovery. + await tester.ensureVisible( + find.byKey(const Key('community-switcher-add')), + ); + await tester.tap(find.byKey(const Key('community-switcher-add'))); + await tester.pumpAndSettle(); + expect(fixture.admitted, hasLength(2)); + expect(find.byType(OnboardingAppEntry), findsOneWidget); + expect(find.text('Home content').hitTestable(), findsNothing); + await tester.pageBack(); + await tester.pumpAndSettle(); + expect(find.byType(OnboardingAppEntry), findsNothing); + expect(find.text('Select a community'), findsOneWidget); + expect(fixture.flow.stage, HostedFlowStage.chooseCommunities); + fixture.admitted.clear(); + + final admission = Completer(); + fixture.beforeAdmission = (_) => admission.future; + await tester.tap(find.byType(CommunityGridOption).last); + await tester.pump(); + // System Back cancels even while the outgoing picker is mounted. + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(fixture.flow.stage, HostedFlowStage.chooseCommunities); + admission.complete(); + await tester.pumpAndSettle(); + expect(fixture.admitted, isEmpty); + final resumedAdmission = Completer(); + fixture.beforeAdmission = (_) => resumedAdmission.future; + await tester.tap(find.byType(CommunityGridOption).last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Home content'), findsNothing); + expect(find.text('Joining your community'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byTooltip('Back'), findsNothing); + expect( + tester.getBottomLeft(find.byType(BeeLoadingMark)).dy, + lessThan(tester.getTopLeft(find.text('Joining your community')).dy), + ); + final bee = tester.widget( + find.descendant( + of: find.byType(BeeLoadingMark), + matching: find.byType(CustomPaint), + ), + ); + expect(bee.size.width, 80); + final mark = tester.getRect(find.byType(BeeLoadingMark)); + expect( + mark.center.dx, + tester.view.physicalSize.width / tester.view.devicePixelRatio / 2, + ); + expect( + (mark.top + + tester.getRect(find.text('Joining your community')).bottom) / + 2, + tester.view.physicalSize.height / tester.view.devicePixelRatio / 2, + ); + expect( + tester.getTopLeft(find.text('Joining your community')).dy - mark.bottom, + 24, + ); + expect( + tester.widget(find.text('Joining your community')).textAlign, + TextAlign.center, + ); + expect( + MediaQuery.disableAnimationsOf( + tester.element(find.byType(BeeLoadingMark)), + ), + reduced, + ); + if (reduced) { + await tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 1), + ); + } + expect(fixture.admitted, isEmpty); + final opening = Completer(); + fixture.beforeOpen = () => opening.future; + resumedAdmission.complete(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Home content'), findsNothing); + opening.complete(); + await tester.pumpAndSettle(); + expect(fixture.admitted, [ + 'two.communities.buzz.xyz', + 'one.communities.buzz.xyz', + ]); + expect(fixture.openedCommunity, 'wss://two.communities.buzz.xyz'); + expect(find.text('Home content').hitTestable(), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + await fixture.dispose(); + } + PendingDeepLinkNotifier.debugUriStreamOverride = null; + }, skip: !HostedEnvironment.enabled); + + testWidgets('step actions remain reachable with large text and keyboard', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 640); + addTearDown(tester.view.reset); + var continued = false; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + size: Size(320, 640), + textScaler: TextScaler.linear(2), + viewInsets: EdgeInsets.only(bottom: 260), + ), + child: OnboardingStepPage( + title: 'Bring your identity with you', + subtitle: 'Use the identity from your other device to continue.', + onBack: () {}, + content: const Text('Your saved identity stays on your device.'), + actions: [ + FilledButton( + onPressed: () => continued = true, + child: const Text('Continue'), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Continue')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Continue')); + expect(continued, isTrue); + expect(tester.takeException(), isNull); + }); +} diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index a0cb332920..f08c1cd656 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/community/community_grid_option.dart'; import 'dart:ui' as ui; import 'package:buzz/shared/hosted/hosted_community_creation.dart'; @@ -67,7 +68,8 @@ void main() { .hitTestable(); expect( tester.getTopLeft(scanCard).dy - tester.getBottomLeft(subtitle).dy, - closeTo(24, 1), + // The transitioning heading reserves the taller of both text blocks. + greaterThanOrEqualTo(24), ); tester.view.physicalSize = const Size(320, 640); await tester.pumpAndSettle(); @@ -267,7 +269,11 @@ void main() { expect(find.byType(HostedConnectPage), findsNothing); await tester.tap(find.text('Continue with Builderlab')); await tester.pumpAndSettle(); - expect(find.byType(CheckboxListTile), findsNothing); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(find.byType(CommunityGridOption), findsWidgets); + await tester.tap(find.byType(CommunityGridOption).first); + await tester.pumpAndSettle(); + expect(find.byType(CommunityGridOption), findsNothing); expect(f.admitted, [ 'one.communities.buzz.xyz', 'two.communities.buzz.xyz', @@ -281,6 +287,10 @@ void main() { }; await tester.tap(find.text('Continue with Builderlab')); await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(find.byType(CommunityGridOption), findsWidgets); + await tester.tap(find.byType(CommunityGridOption).first); + await tester.pumpAndSettle(); expect(find.text('Keep saved identity and skip'), findsOneWidget); expect(find.text('1 community added and kept.'), findsOneWidget); await tester.tap(find.text('Keep saved identity and skip')); @@ -293,18 +303,30 @@ void main() { }; await tester.tap(find.text('Continue with Builderlab')); await tester.pumpAndSettle(); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(find.byType(CommunityGridOption), findsWidgets); + await tester.tap(find.byType(CommunityGridOption).first); + await tester.pumpAndSettle(); expect(find.text('1 community added and kept.'), findsOneWidget); expect(find.text('Try again').hitTestable(), findsNothing); + expect(find.text('Skip this community').hitTestable(), findsOneWidget); await tester.tap(find.text('Back to choices')); await tester.pumpAndSettle(); - expect(find.byType(CheckboxListTile), findsOneWidget); + expect(find.byType(CommunityGridOption), findsNWidgets(2)); expect( find.descendant( - of: find.byType(CheckboxListTile), + of: find.byType(CommunityGridOption), matching: find.text('two.communities.buzz.xyz'), ), findsWidgets, ); + await tester.tap(find.byType(CommunityGridOption).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Skip this community')); + await tester.pumpAndSettle(); + expect(f.admitted, ['one.communities.buzz.xyz']); + expect(f.openedCommunity, 'wss://one.communities.buzz.xyz'); + expect(find.byType(HostedConnectPage), findsNothing); } }, skip: HostedEnvironment.authDomains.isEmpty, diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 91c6376c2d..6de0e4662d 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -1,4 +1,7 @@ +import 'package:buzz/shared/hosted/hosted_community_creation.dart'; +import 'package:buzz/shared/community/community_grid_option.dart'; import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/hosted_flow.dart'; import 'package:buzz/shared/hosted/hosted_environment.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; @@ -65,7 +68,11 @@ void main() { f.bound = null; f.offerProfile = false; await f.mount(tester); - await f.tap(tester, 'Continue with Builderlab'); + // Account restoration may resume independently of the Add choices. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.setupIdentity); + expect(f.probes, isEmpty); expect(find.text('Create my Buzz identity'), findsOneWidget); expect(find.text('Import existing identity'), findsOneWidget); expect(await f.store.read(), isNull); @@ -114,7 +121,11 @@ void main() { await f.initialize(localIdentity: false); f.offerProfile = false; await f.mount(tester); - await f.tap(tester, 'Continue with Builderlab'); + // Account restoration may resume independently of the Add choices. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.setupIdentity); + expect(f.probes, isEmpty); expect(find.text('Create my Buzz identity'), findsNothing); expect(find.text('Import existing identity'), findsOneWidget); await f.tap(tester, 'Import existing identity'); @@ -140,21 +151,25 @@ void main() { expect(f.requests.where((r) => r.url.path.endsWith('/recovery')), isEmpty); }); - journey('system Back leaves community creation', (tester, f) async { + journey('system Back leaves community link entry', (tester, f) async { await f.initialize(); await f.mount(tester); - await f.tap(tester, 'Create a community'); + await f.tap(tester, 'Enter a community link'); expect( - tester.widget(find.byType(PairingPage)).creatingCommunity, + tester + .widget(find.byType(PairingPage)) + .enteringCommunityLink, isTrue, ); await tester.binding.handlePopRoute(); await f.settle(tester); expect( - tester.widget(find.byType(PairingPage)).creatingCommunity, + tester + .widget(find.byType(PairingPage)) + .enteringCommunityLink, isFalse, ); - expect(find.text('Create a community').hitTestable(), findsOneWidget); + expect(find.text('Enter a community link').hitTestable(), findsOneWidget); }); journey('retiring profile setup removes both profile and avatar routes', ( @@ -164,7 +179,13 @@ void main() { await f.initialize(); f.rows.add(f.row('one')); await f.mount(tester); - await f.tap(tester, 'Continue with Builderlab'); + // Account restoration may resume independently of the Add choices. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(f.probes, isEmpty); + await tester.tap(find.byType(CommunityGridOption).first); + await f.settle(tester); await tester.tap(find.byTooltip('Add profile photo')); await f.settle(tester); expect(find.byType(ProfileSetupAvatarEditor), findsOneWidget); @@ -184,7 +205,13 @@ void main() { await f.initialize(); f.rows.add(f.row('one')); await f.mount(tester); - await f.tap(tester, 'Continue with Builderlab'); + // Account restoration may resume independently of the Add choices. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(f.probes, isEmpty); + await tester.tap(find.byType(CommunityGridOption).first); + await f.settle(tester); await tester.tap(find.byTooltip('Add profile photo')); await f.settle(tester); final url = emojiAvatarDataUrl('🐝', 0xFFFFE75C); @@ -233,7 +260,8 @@ void main() { findsOneWidget, ); expect(f.activeFlow, isNull); - await f.tap(tester, 'Back'); + await tester.tap(find.byTooltip('Back').hitTestable()); + await f.settle(tester); expect(f.requests, isEmpty); } final notifier = f.container.read(pairingProvider.notifier); @@ -495,7 +523,9 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); try { - await tester.tap(find.text('Leave setup').hitTestable()); + expect(find.byTooltip('Back'), findsNothing); + // External teardown still fences pending enrollment. + await f.flow.leave(); await tester.pump(); } finally { enrollmentReturn.complete(); @@ -590,7 +620,14 @@ void main() { f.subject = 'new-owner'; f.bound = boundAccount ? f.secondKeys.public : null; if (boundAccount) f.cloudKeys.add(f.secondKeys); - await f.tap(tester, 'Continue with Builderlab'); + // Account restoration may resume independently of the Add choices. + await f.flow.signInAccount(); + await f.settle(tester); + expect( + f.flow.stage, + boundAccount ? HostedFlowStage.idle : HostedFlowStage.chooseIdentity, + ); + expect(f.probes, isEmpty); if (!boundAccount) { expect(find.text('Saved identity'), findsOneWidget); expect(f.sent('/verify'), isEmpty); @@ -626,7 +663,11 @@ void main() { await f.storage.save(legacy); await f.storage.saveActiveId(legacy.id); await f.mount(tester); - await f.tap(tester, 'Continue with Builderlab'); + // Account restoration may resume independently of the Add choices. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseIdentity); + expect(f.probes, isEmpty); expect(find.text('Use Builderlab identity'), findsNWidgets(2)); await f.tap(tester, fullNpub(f.secondKeys.public)!); expect((await f.store.read())!.pubkey, f.secondKeys.public); @@ -641,7 +682,9 @@ void main() { findsNothing, ); f.rejectList = false; - await f.tap(tester, 'Continue with Builderlab'); + await f.tap(tester, 'Try again'); + expect(f.flow.stage, HostedFlowStage.idle); + expect(f.probes, isEmpty); expect( find.text( 'No communities available to add. Scan a QR code or use an invite link.', @@ -651,8 +694,14 @@ void main() { expect(find.text('Scan a QR code'), findsOneWidget); expect(find.text('Use pairing code'), findsNothing); f.rows.addAll([f.row('one'), f.row('two')]); - await f.tap(tester, 'Continue with Builderlab'); - // Joined communities are added automatically after choosing the identity. + // Account restoration may resume independently of the Add choices. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(f.probes, isEmpty); + await tester.tap(find.byType(CommunityGridOption).first); + await f.settle(tester); + // Continue explicitly admits the selected communities. expect(find.byType(CheckboxListTile).hitTestable(), findsNothing); expect( find.byType(ProfileSetupPage, skipOffstage: false), @@ -705,18 +754,32 @@ void main() { (tester, f) async { await f.initialize(localIdentity: false); f.cloudKeys.addAll([f.secondKeys, f.keys]); - f.rows.add(f.row('one')); + f.rows.addAll([f.row('one'), f.row('two')]); f.offerProfile = false; f.profileUnavailable = unreadableProfile; await f.mount(tester); - await f.tap(tester, 'Continue with Builderlab'); - // One discovered community is admitted automatically. + // Account restoration may resume independently of the Add choices. + await f.flow.signInAccount(); + await f.settle(tester); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(f.probes, isEmpty); + await tester.tap(find.byType(CommunityGridOption).last); + await f.settle(tester); + // All memberships are restored; the second is the chosen destination. expect(f.profileWrites, isEmpty); expect( find.byType(ProfileSetupPage, skipOffstage: false), findsNothing, ); - final saved = (await f.storage.loadAll()).single; + final communities = await f.storage.loadAll(); + expect(communities, hasLength(2)); + expect( + communities.map((c) => c.name), + containsAll(['one.communities.buzz.xyz', 'two.communities.buzz.xyz']), + ); + final saved = communities.singleWhere( + (c) => c.name == 'two.communities.buzz.xyz', + ); expect(saved.nsec, f.keys.nsec); expect(saved.pubkey, f.keys.public); expect((await f.store.read())!.pubkey, f.keys.public); @@ -907,9 +970,9 @@ void main() { await f.initialize(); f.loseCreateResponse = true; await f.mount(tester); - await f.tap(tester, 'Create a community'); - await tester.enterText(find.byType(TextField).hitTestable(), 'new-home'); - await f.tap(tester, 'Continue'); + // Creation is entered elsewhere; retain its restart/persistence journey. + await f.flow.start(HostedCreateCommunity('new-home')); + await f.settle(tester); expect(find.text('Try again').hitTestable(), findsOneWidget); expect(f.owned, hasLength(1)); expect(await f.storage.loadAll(), isEmpty); @@ -980,7 +1043,8 @@ void main() { // Owner mismatch requires the correct identity, not an automatic // retry. Explicitly abandon setup, then reopen the same link after // the server-side owner conflict has been corrected. - await f.tap(tester, 'Leave setup'); + await tester.tap(find.byTooltip('Back').hitTestable()); + await f.settle(tester); expect((await f.store.read())!.pubkey, f.keys.public); await f.link(tester, 'one'); } diff --git a/mobile/test/features/onboarding/onboarding_entry_routes_test.dart b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart index 6ea6b927f7..c27a84b888 100644 --- a/mobile/test/features/onboarding/onboarding_entry_routes_test.dart +++ b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart @@ -1,3 +1,5 @@ +import '../../helpers/two_finger_double_tap.dart'; +import 'package:buzz/shared/community/community_grid_option.dart'; import 'package:buzz/shared/hosted/hosted_environment.dart'; import 'dart:async'; import 'dart:convert'; @@ -19,6 +21,7 @@ import 'package:buzz/shared/hosted/self_hosted_entry.dart'; import 'package:buzz/shared/hosted/account_identity_store.dart'; import 'package:buzz/shared/widgets/buzz_sheet_header.dart'; import 'package:flutter/material.dart'; +import 'package:buzz/shared/widgets/bee_loading_mark.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -95,12 +98,9 @@ void main() { } Future pair() async { - final trigger = find - .byKey(const Key('developer-pairing-trigger')) - .hitTestable(); - await tester.tap(trigger); - await tester.pump(const Duration(milliseconds: 50)); - await tester.tap(trigger); + final trigger = find.byKey(const Key('developer-pairing-trigger')); + await twoFingerDoubleTap(tester, tester.getCenter(trigger)); + await tester.tap(find.text('Use a pairing code').hitTestable()); await tester.pumpAndSettle(); await tester.enterText( find.byType(TextField).hitTestable(), @@ -128,7 +128,15 @@ void main() { } }; await openEntry(); - await tester.tap(find.text('Continue with Builderlab').hitTestable()); + expect(find.text('Create a community'), findsNothing); + expect(find.text('Continue with Builderlab'), findsNothing); + expect(find.text('Use a pairing code'), findsNothing); + // An independently resumed account restore still owns a root modal + // above Add; Add itself no longer offers account sign-in or creation. + await f.flow.signInAccount(); + await tester.pumpAndSettle(); + expect(f.admitted, isEmpty); + await tester.tap(find.byType(CommunityGridOption).first); await tester.pumpAndSettle(); expect(f.admitted, ['one.communities.buzz.xyz']); expect(f.flow.stage, HostedFlowStage.communityConflict); @@ -139,19 +147,17 @@ void main() { expect(find.text('Back to choices').hitTestable(), findsOneWidget); await tester.tap(find.text('Back to choices').hitTestable()); await tester.pumpAndSettle(); - expect(find.byType(CheckboxListTile).hitTestable(), findsOneWidget); + expect(find.byType(CommunityGridOption).hitTestable(), findsNWidgets(2)); expect( - (tester - .widget( - find.byType(CheckboxListTile).hitTestable(), - ) - .title - as Text) - .data, + tester + .widget( + find.byType(CommunityGridOption).hitTestable().last, + ) + .name, 'two.communities.buzz.xyz', ); f.beforeAdmission = null; - await tester.tap(find.text('Continue').hitTestable()); + await tester.tap(find.byType(CommunityGridOption).first); await tester.pumpAndSettle(); expect(f.admitted, [ 'one.communities.buzz.xyz', @@ -170,13 +176,13 @@ void main() { await pair(); expect(f.flow.accountOnly, isFalse); expect(f.flow.stage, HostedFlowStage.working); - expect(find.text('Leave setup').hitTestable(), findsOneWidget); + expect(find.byTooltip('Back').hitTestable(), findsNothing); + expect(find.byType(BeeLoadingMark).hitTestable(), findsOneWidget); + expect(find.byType(BuzzSheetHeader), findsNothing); expect( - find.byType(CircularProgressIndicator).hitTestable(), + find.byType(HostedConnectPage, skipOffstage: false), findsOneWidget, ); - expect(find.byType(BuzzSheetHeader), findsNothing); - expect(find.byType(HostedConnectPage), findsNothing); admission.completeError(AccountFailure.unavailable); await tester.pumpAndSettle(); expect(f.flow.stage, HostedFlowStage.blocked); @@ -187,7 +193,7 @@ void main() { findsOneWidget, ); expect(find.text('Try again').hitTestable(), findsOneWidget); - expect(find.text('Leave setup').hitTestable(), findsOneWidget); + expect(find.byTooltip('Back').hitTestable(), findsOneWidget); expect(find.byType(BuzzSheetHeader), findsNothing); f.beforeAdmission = null; await tester.tap(find.text('Try again').hitTestable()); @@ -209,7 +215,7 @@ void main() { await f.flow.leave(); await tester.pumpAndSettle(); expect(f.flow.stage, HostedFlowStage.idle); - await tester.tap(find.text('Back').hitTestable()); + await tester.tap(find.byTooltip('Back').hitTestable()); await tester.pumpAndSettle(); expect(find.text('Enter a community link').hitTestable(), findsOneWidget); expect(container.read(hostedInlineEntryProvider), isTrue); @@ -218,9 +224,28 @@ void main() { await openEntry(); } - // Drive the real deep-link dispatcher, sheet, service and auth persistence. + // The selected button becomes Continue without replacing its element. + final linkAction = find.byKey(const Key('add-community-link-action')); + final originalAction = tester.element(linkAction); await tester.tap(find.text('Enter a community link').hitTestable()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.element(linkAction), same(originalAction)); await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const Key('add-community-link-rise'))) + .transform + .storage[13], + 0, + ); + await tester.tap(find.text('Continue').hitTestable()); + await tester.pumpAndSettle(); + expect( + find.text('Enter a valid community address or invite link.'), + findsOneWidget, + ); + // Drive the real deep-link dispatcher, sheet, service and auth persistence. await tester.enterText( find.byKey(const Key('community-link-input')).hitTestable(), 'new-community.example', @@ -254,17 +279,14 @@ void main() { final joinHeight = tester.getSize(find.byType(SelfHostedEntrySheet)).height; await tester.tap(find.text('Import a Nostr key').hitTestable()); await tester.pumpAndSettle(); - expect(find.byType(BuzzSheetHeader), findsOneWidget); + expect(find.byType(BuzzSheetHeader), findsNothing); expect(find.text('Import identity'), findsOneWidget); expect( ModalRoute.of(tester.element(find.byType(NsecImportSheet))), - same(joinRoute), + isNot(same(joinRoute)), ); - expect( - tester.getSize(find.byType(SelfHostedEntrySheet)).height, - greaterThan(joinHeight), - ); - await tester.tap(find.byTooltip('Close sheet')); + expect(find.byType(SelfHostedEntrySheet), findsNothing); + await tester.tap(find.byTooltip('Back')); await tester.pumpAndSettle(); expect(find.byType(SelfHostedEntrySheet), findsOneWidget); expect(find.byType(NsecImportSheet), findsNothing); @@ -289,8 +311,8 @@ void main() { await tester.tap(find.text('Import this identity')); await tester.pumpAndSettle(); expect(probes, 1); - await tester.tap(find.byTooltip('Close sheet')); - await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Back')); + // Completion races the outgoing route animation. pendingProbe.complete(); await tester.pumpAndSettle(); expect(find.text('Join community'), findsOneWidget); @@ -343,7 +365,7 @@ void main() { await openEntry(); await tester.tap(find.text('Enter a community link').hitTestable()); await tester.pumpAndSettle(); - await tester.tap(find.text('Back').hitTestable()); + await tester.tap(find.byTooltip('Back').hitTestable()); await tester.pumpAndSettle(); expect(container.read(hostedInlineEntryProvider), isTrue); await tester.pageBack(); diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index c5de9ccb56..8c49c82792 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -293,14 +293,19 @@ void main() { expect(find.text('Does your desktop app show this code?'), findsNothing); }); - testWidgets('uses Cancel as the only visible SAS exit', (tester) async { + testWidgets('fullscreen pairing supports Cancel and system Back', ( + tester, + ) async { final notifier = _ConfirmingSasPairingNotifier(); await tester.pumpWidget( ProviderScope( overrides: [pairingProvider.overrideWith(() => notifier)], child: MaterialApp( theme: AppTheme.dark(), - home: const PairingPage(addingCommunity: true), + home: const PairingPage( + addingCommunity: true, + connectionLanding: true, + ), ), ), ); @@ -312,6 +317,9 @@ void main() { await tester.tap(find.widgetWithText(TextButton, 'Cancel')); expect(notifier.denied, isTrue); + notifier.denied = false; + await tester.binding.handlePopRoute(); + expect(notifier.denied, isTrue); }); testWidgets('keeps only Back on the clear add-community header', ( diff --git a/mobile/test/features/pairing/pairing_qr_scanner_test.dart b/mobile/test/features/pairing/pairing_qr_scanner_test.dart index 2aa20649a5..e7271d305f 100644 --- a/mobile/test/features/pairing/pairing_qr_scanner_test.dart +++ b/mobile/test/features/pairing/pairing_qr_scanner_test.dart @@ -286,89 +286,105 @@ void main() { }, ); - testWidgets( - 'welcome content follows the scanner spring and returns on close', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { - return call.method == 'usesDynamicIslandQrScannerPortal' - ? true - : null; - }); - await tester.binding.setSurfaceSize(const Size(393, 852)); - addTearDown(() => tester.binding.setSurfaceSize(null)); - for (final reduced in [false, true]) { - await tester.pumpWidget( - WidgetHelpers.testable( - disableAnimations: reduced, - child: const PairingPage( - connectionLanding: true, - showConnectionOptions: false, - ), + testWidgets('welcome and Add follow the scanner spring and return on close', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { + return call.method == 'usesDynamicIslandQrScannerPortal' + ? true + : null; + }); + await tester.binding.setSurfaceSize(const Size(393, 852)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + for (final (reduced, adding) in [ + (false, false), + (true, false), + (false, true), + (true, true), + ]) { + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: reduced, + child: PairingPage( + addingCommunity: adding, + connectionLanding: true, + showConnectionOptions: false, ), - ); - await tester.pumpAndSettle(); - final opacity = find.byKey(const Key('scanner-welcome-opacity')); - final scale = find.byKey(const Key('scanner-welcome-scale')); - expect(tester.widget(opacity).opacity, 1); - await tester.tap(find.text('Scan a QR code').hitTestable()); - await tester.pump(); - // Opening must begin without waiting for the welcome double-tap timeout. + ), + ); + await tester.pumpAndSettle(); + final opacity = find.byKey(const Key('scanner-welcome-opacity')); + final scale = find.byKey(const Key('scanner-welcome-scale')); + expect(tester.widget(opacity).opacity, 1); + if (adding) { + // Only foreground controls transition; the opaque page stays fixed. expect( - find.byKey(const ValueKey('dynamic-island-qr-scanner-portal')), - findsOneWidget, - ); - await tester.pump(const Duration(milliseconds: 100)); - if (!reduced) { - final portal = find.byKey( - const ValueKey('dynamic-island-qr-scanner-portal'), - ); - final progress = (tester.getSize(portal).height - 36) / (363 - 36); - expect( - tester.widget(opacity).opacity, - closeTo(1 - progress, 0.001), - ); - expect( - tester.widget(scale).transform.storage[0], - closeTo(1 - 0.04 * progress, 0.001), - ); - } - await tester.pumpAndSettle(); - expect(tester.widget(opacity).opacity, 0); - expect( - tester.widget(scale).transform.storage[0], - closeTo(reduced ? 1 : 0.96, 0.001), - ); - expect( - find - .ancestor( - of: find.text('Scan a QR code'), - matching: find.byType(FilledButton), - ) - .hitTestable(), + find.descendant(of: opacity, matching: find.byType(Scaffold)), findsNothing, ); - await tester.tapAt(const Offset(196.5, 700)); - await tester.pumpAndSettle(); - expect(tester.widget(opacity).opacity, 1); - expect(tester.widget(scale).transform.storage[0], 1); expect( - find - .ancestor( - of: find.text('Scan a QR code'), - matching: find.byType(FilledButton), - ) - .hitTestable(), - findsOneWidget, + find.ancestor(of: opacity, matching: find.byType(Scaffold)), + findsWidgets, ); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pumpAndSettle(); } - debugDefaultTargetPlatformOverride = null; - }, - ); + await tester.tap(find.text('Scan a QR code').hitTestable()); + await tester.pump(); + // Opening must begin without waiting for the welcome double-tap timeout. + expect( + find.byKey(const ValueKey('dynamic-island-qr-scanner-portal')), + findsOneWidget, + ); + await tester.pump(const Duration(milliseconds: 100)); + if (!reduced) { + final portal = find.byKey( + const ValueKey('dynamic-island-qr-scanner-portal'), + ); + final progress = (tester.getSize(portal).height - 36) / (363 - 36); + expect( + tester.widget(opacity).opacity, + closeTo(1 - progress, 0.001), + ); + expect( + tester.widget(scale).transform.storage[0], + closeTo(1 - 0.04 * progress, 0.001), + ); + } + await tester.pumpAndSettle(); + expect(tester.widget(opacity).opacity, 0); + expect( + tester.widget(scale).transform.storage[0], + closeTo(reduced ? 1 : 0.96, 0.001), + ); + expect( + find + .ancestor( + of: find.text('Scan a QR code'), + matching: find.byType(FilledButton), + ) + .hitTestable(), + findsNothing, + ); + await tester.tapAt(const Offset(196.5, 700)); + await tester.pumpAndSettle(); + expect(tester.widget(opacity).opacity, 1); + expect(tester.widget(scale).transform.storage[0], 1); + expect( + find + .ancestor( + of: find.text('Scan a QR code'), + matching: find.byType(FilledButton), + ) + .hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } + debugDefaultTargetPlatformOverride = null; + }); testWidgets( 'an early dismissal keeps its frame and settles back into the island', diff --git a/mobile/test/helpers/hosted_communities_fixture.dart b/mobile/test/helpers/hosted_communities_fixture.dart index dcf3244d32..7649094122 100644 --- a/mobile/test/helpers/hosted_communities_fixture.dart +++ b/mobile/test/helpers/hosted_communities_fixture.dart @@ -81,6 +81,8 @@ class HostedCommunitiesFixture { late BuilderlabAccountClient account; late final HostedFlow flow; final admitted = []; + String? openedCommunity; + Future Function()? beforeOpen; final remembered = []; final requests = []; List> rows = []; @@ -171,6 +173,10 @@ class HostedCommunitiesFixture { opened = uri; return true; }, + openLandingCommunity: (link, subject, pubkey, current) async { + await beforeOpen?.call(); + if (current()) openedCommunity = link.relayUrl; + }, enterCommunity: (link, subject, pubkey, current) async { final target = link as AddCommunityDeepLink; await beforeAdmission?.call(target.name); diff --git a/mobile/test/helpers/two_finger_double_tap.dart b/mobile/test/helpers/two_finger_double_tap.dart new file mode 100644 index 0000000000..581642f9aa --- /dev/null +++ b/mobile/test/helpers/two_finger_double_tap.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; + +Future twoFingerDoubleTap(WidgetTester tester, Offset position) async { + for (final time in [100, 200]) { + final first = await tester.createGesture(); + final second = await tester.createGesture(); + await first.down(position, timeStamp: Duration(milliseconds: time)); + await second.down( + position + const Offset(40, 0), + timeStamp: Duration(milliseconds: time + 5), + ); + await first.up(timeStamp: Duration(milliseconds: time + 20)); + await second.up(timeStamp: Duration(milliseconds: time + 25)); + } + await tester.pumpAndSettle(); +} diff --git a/mobile/test/shared/hosted/hosted_communities_flow_test.dart b/mobile/test/shared/hosted/hosted_communities_flow_test.dart index 0b5c97c17c..b7952ec129 100644 --- a/mobile/test/shared/hosted/hosted_communities_flow_test.dart +++ b/mobile/test/shared/hosted/hosted_communities_flow_test.dart @@ -42,6 +42,33 @@ void main() { ); } + test( + 'selecting a skipped relay retries it as the landing community', + () async { + final f = await fixture(); + f.rows = [f.row('one'), f.row('two'), f.row('three')]; + f.beforeAdmission = (name) async { + if (name != 'one.communities.buzz.xyz') { + throw AccountFailure.unavailable; + } + }; + await f.flow.signInAccount(); + await f.flow.addSelectedCommunities(); + await f.flow.skipFailedCommunity(); + expect(f.flow.stage, HostedFlowStage.blocked); + f.flow.backToCommunityChoices(); + f.flow.chooseLandingCommunity('wss://two.communities.buzz.xyz'); + f.beforeAdmission = null; + await f.flow.addSelectedCommunities(); + expect(f.admitted, [ + 'one.communities.buzz.xyz', + 'two.communities.buzz.xyz', + 'three.communities.buzz.xyz', + ]); + expect(f.openedCommunity, 'wss://two.communities.buzz.xyz'); + }, + ); + test('malformed list targets cannot authorize admission', () async { final f = await fixture(); f.rows = [ @@ -61,6 +88,9 @@ void main() { } }; await f.flow.signInAccount(); + expect(f.flow.stage, HostedFlowStage.chooseCommunities); + expect(f.admitted, isEmpty); + await f.flow.addSelectedCommunities(); expect(f.admitted, ['a-one.communities.buzz.xyz']); f.expired = true; await f.flow.retry(); @@ -80,7 +110,8 @@ void main() { ); final completed = Completer(); f.flow.addListener(() { - if (f.flow.stage == HostedFlowStage.complete && !completed.isCompleted) { + if (f.flow.stage == HostedFlowStage.chooseCommunities && + !completed.isCompleted) { completed.complete(); } }); @@ -88,6 +119,7 @@ void main() { BrowserAuthCode('other-code', 'a' * 43, {'type': 'account'}), ); await completed.future.timeout(const Duration(seconds: 2)); + await f.flow.addSelectedCommunities(); expect(f.admitted, [ 'a-one.communities.buzz.xyz', 'b-one.communities.buzz.xyz',