From f7708d0694dbbd92bb6f4cd2b6872f4bbdc3cebb Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 22 Oct 2026 10:27:38 -0700 Subject: [PATCH] Polish mobile onboarding and profile setup (#8438) Originally stacked on #8375, now merged. Rebased onto main. Polishes the onboarding landing page, community-link entry, and recovery sheets. Profile setup uses a consistent sheet with a larger avatar, inline display name, and draft photo/emoji/animated editing; iOS uses native controls and coordinated avatar/sheet transitions. Validation: mobile analysis, full tests, and normal pre-push checks passed after rebase. Prior iOS build and device walkthrough passed; native validation was not repeated after this rebase. Snapshots omitted as requested. --------- Signed-off-by: kenny lopez Signed-off-by: Tom Brow Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 + mobile/ios/Runner/AppDelegate.swift | 24 + mobile/ios/Runner/NativeCommunityEntry.swift | 395 ++++++++++++++ mobile/ios/Runner/NativeProfileSetup.swift | 362 +++++++++++++ .../ProfileSetupRecoveryTests.swift | 98 ++++ mobile/ios/RunnerTests/RunnerTests.swift | 53 ++ .../features/invites/invite_join_sheet.dart | 33 +- .../onboarding/self_hosted_entry_sheet.dart | 192 +++++-- mobile/lib/features/pairing/pairing_page.dart | 18 + .../pairing_page/community_link_form.dart | 157 +++++- .../pairing_page/onboarding_colors.dart | 4 +- .../onboarding_connection_view.dart | 500 ++++++++++++++++++ .../pairing_page/pairing_welcome_view.dart | 167 ++++-- .../pairing_page/sas_verification_view.dart | 2 +- .../profile/animated_avatar_capture.dart | 6 + .../profile/profile_avatar_draft.dart | 27 + .../profile/profile_avatar_editor.dart | 19 +- .../profile/profile_avatar_sheet_app.dart | 42 ++ .../features/profile/profile_provider.dart | 6 + .../profile/profile_setup_avatar_editor.dart | 175 ++++++ .../profile/profile_setup_empty_avatar.dart | 62 +++ .../features/profile/profile_setup_page.dart | 311 +++++++++++ mobile/lib/hosted_app_gate.dart | 6 +- mobile/lib/onboarding_app_entry.dart | 16 +- mobile/lib/shared/hosted/hosted_flow.dart | 19 +- .../shared/hosted/hosted_flow_provider.dart | 21 +- .../native_community_entry.dart | 129 +++++ .../identity_transfer/nsec_import_sheet.dart | 91 +++- mobile/lib/shared/theme/buzz_icons.dart | 3 + .../lib/shared/widgets/buzz_sheet_header.dart | 15 +- .../widgets/buzz_titled_sheet_layout.dart | 5 + .../shared/widgets/modal_presentation.dart | 5 +- .../features/channels/channels_page_test.dart | 3 +- .../onboarding/hosted_communities_test.dart | 204 ++++++- .../onboarding/hosted_journeys_test.dart | 107 +++- .../native_entry_fallback_test.dart | 58 ++ .../onboarding_entry_routes_test.dart | 73 ++- .../features/pairing/pairing_page_test.dart | 4 +- .../profile_setup_avatar_editor_test.dart | 100 ++++ .../profile_setup_native_name_test.dart | 67 +++ .../test/helpers/hosted_journey_fixture.dart | 7 + .../hosted/hosted_profile_offer_test.dart | 42 ++ .../native_community_entry_test.dart | 174 ++++++ .../nsec_import_sheet_test.dart | 93 +++- mobile/test/widget_test.dart | 14 +- 45 files changed, 3749 insertions(+), 172 deletions(-) create mode 100644 mobile/ios/Runner/NativeCommunityEntry.swift create mode 100644 mobile/ios/Runner/NativeProfileSetup.swift create mode 100644 mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift create mode 100644 mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart create mode 100644 mobile/lib/features/profile/profile_avatar_sheet_app.dart create mode 100644 mobile/lib/features/profile/profile_setup_avatar_editor.dart create mode 100644 mobile/lib/features/profile/profile_setup_empty_avatar.dart create mode 100644 mobile/lib/features/profile/profile_setup_page.dart create mode 100644 mobile/lib/shared/identity_transfer/native_community_entry.dart create mode 100644 mobile/test/features/onboarding/native_entry_fallback_test.dart create mode 100644 mobile/test/features/profile/profile_setup_avatar_editor_test.dart create mode 100644 mobile/test/features/profile/profile_setup_native_name_test.dart create mode 100644 mobile/test/shared/identity_transfer/native_community_entry_test.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 142fe15921..5f420fcbcf 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + F84380010000000000000001 /* ProfileSetupRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */; }; BBAA00000000000000000011 /* IdentityFilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAA00000000000000000012 /* IdentityFilePlugin.swift */; }; BBAA00000000000000000001 /* HostedHpkeBackup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAA00000000000000000002 /* HostedHpkeBackup.swift */; }; DAAB00000000000000000001 /* NativeTabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAB00000000000000000002; }; @@ -35,6 +36,8 @@ 4A71C0152F40B00100A17E01 /* HuddleMediaPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */; }; 4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */; }; 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; }; + 7B81D0192F40D00100A17E01 /* NativeCommunityEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81D01A2F40D00100A17E01 /* NativeCommunityEntry.swift */; }; + 7B81D0192F40D00100A17E91 /* NativeProfileSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81D01A2F40D00100A17E91 /* NativeProfileSetup.swift */; }; 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; }; 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */; }; CA8300012F50000100A17E01 /* IosMediaCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8300022F50000100A17E01 /* IosMediaCarousel.swift */; }; @@ -98,6 +101,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupRecoveryTests.swift; sourceTree = ""; }; BBAA00000000000000000012 /* IdentityFilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFilePlugin.swift; sourceTree = ""; }; BBAA00000000000000000002 /* HostedHpkeBackup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedHpkeBackup.swift; sourceTree = ""; }; DAAB00000000000000000002 /* NativeTabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTabler.swift; sourceTree = ""; }; @@ -129,6 +133,8 @@ 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuddleMediaPlugin.swift; sourceTree = ""; }; 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuddleAudioEngine.swift; sourceTree = ""; }; 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileTextEditor.swift; sourceTree = ""; }; + 7B81D01A2F40D00100A17E01 /* NativeCommunityEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeCommunityEntry.swift; sourceTree = ""; }; + 7B81D01A2F40D00100A17E91 /* NativeProfileSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileSetup.swift; sourceTree = ""; }; 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSkinToneControl.swift; sourceTree = ""; }; 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePaginationGlassControl.swift; sourceTree = ""; }; CA8300022F50000100A17E01 /* IosMediaCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosMediaCarousel.swift; sourceTree = ""; }; @@ -202,6 +208,7 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */, AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */, AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */, BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */, @@ -305,6 +312,8 @@ C0AF00000000000000000022 /* NativePresenceMenu.swift */, C0AF00000000000000000012 /* NativeAddMembersSheet.swift */, 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, + 7B81D01A2F40D00100A17E01 /* NativeCommunityEntry.swift */, + 7B81D01A2F40D00100A17E91 /* NativeProfileSetup.swift */, 4A71C01A2F40D00100A17E02 /* MeAvatarLoader.swift */, 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */, 4A71C01E2F40F00100A17E01 /* ThemePaginationGlassControl.swift */, @@ -609,6 +618,7 @@ buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + F84380010000000000000001 /* ProfileSetupRecoveryTests.swift in Sources */, AB1200032F40400100A17E01 /* NativeMessageImageLoaderTests.swift in Sources */, AB1200032F40400100A17E09 /* IdentityFilePluginTests.swift in Sources */, BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */, @@ -647,6 +657,8 @@ C0AF00000000000000000021 /* NativePresenceMenu.swift in Sources */, C0AF00000000000000000011 /* NativeAddMembersSheet.swift in Sources */, 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */, + 7B81D0192F40D00100A17E01 /* NativeCommunityEntry.swift in Sources */, + 7B81D0192F40D00100A17E91 /* NativeProfileSetup.swift in Sources */, 4A71C0192F40D00100A17E02 /* MeAvatarLoader.swift in Sources */, 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */, 4A71C01D2F40F00100A17E01 /* ThemePaginationGlassControl.swift in Sources */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 67ec31d951..0da24ca08d 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -30,6 +30,8 @@ import os.log endpointGrantStore: endpointGrantStore, keychainAccessGroup: pushKeychainAccessGroup ) + private var communityKeyboardChannel: FlutterMethodChannel? + private var communityKeyboardObserver: NSObjectProtocol? private var hapticsChannel: FlutterMethodChannel? private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? @@ -44,6 +46,8 @@ import os.log private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator? private var nativeAddMembersSheetCoordinator: NativeAddMembersSheetCoordinator? private var nativeConfirmationDialogCoordinator: NativeConfirmationDialogCoordinator? + private var nativeProfileSetupCoordinator: NativeProfileSetupCoordinator? + private var nativeCommunityEntryCoordinator: NativeCommunityEntryCoordinator? private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator? private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel? private var identityFilePlugin: IdentityFilePlugin? @@ -68,6 +72,20 @@ import os.log } let messenger = engineBridge.applicationRegistrar.messenger() + let keyboardChannel = FlutterMethodChannel( + name: "buzz/community_keyboard", binaryMessenger: messenger) + communityKeyboardChannel = keyboardChannel + if let observer = communityKeyboardObserver { + NotificationCenter.default.removeObserver(observer) + } + communityKeyboardObserver = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main + ) { _ in + // Deliver after UIKit finishes preparing the keyboard in this run-loop turn. + DispatchQueue.main.async { + keyboardChannel.invokeMethod("willShow", arguments: nil) + } + } identityFilePlugin = IdentityFilePlugin(messenger: messenger) browserAuthBridge = BuzzBrowserAuthBridge(messenger: messenger) hostedHpkeChannel = FlutterMethodChannel(name: "buzz/hosted_hpke", binaryMessenger: messenger) @@ -292,6 +310,12 @@ import os.log )?.viewController ) + nativeProfileSetupCoordinator = NativeProfileSetupCoordinator(messenger: messenger) { [weak self] engine in + let channel = FlutterMethodChannel(name: "buzz/media_upload", binaryMessenger: engine.binaryMessenger) + channel.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) } + } + nativeCommunityEntryCoordinator = NativeCommunityEntryCoordinator(messenger: messenger) + let nativeProfileTextEditorRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeProfileTextEditor" ) diff --git a/mobile/ios/Runner/NativeCommunityEntry.swift b/mobile/ios/Runner/NativeCommunityEntry.swift new file mode 100644 index 0000000000..9338c4041a --- /dev/null +++ b/mobile/ios/Runner/NativeCommunityEntry.swift @@ -0,0 +1,395 @@ +import Flutter +import UIKit +import CoreText + +/// One system sheet owns both the identity choices and the import detent. +final class NativeCommunityEntryCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { + private let channel: FlutterMethodChannel + private var pending: FlutterResult? + private var navigation: UINavigationController? + private var session: Int? + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: "buzz/community_entry", binaryMessenger: messenger) + super.init() + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { return } + guard call.method == "present" else { result(FlutterMethodNotImplemented); return } + guard self.pending == nil else { + result(FlutterError(code: "already_presented", message: nil, details: nil)); return + } + guard let args = call.arguments as? [String: Any], let session = args["session"] as? Int else { + result(FlutterError(code: "invalid_arguments", message: nil, details: nil)); return + } + let root = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive }.flatMap(\.windows) + .first(where: \.isKeyWindow)?.rootViewController + guard var presenter = root else { + result(FlutterError(code: "presentation_failed", message: nil, details: nil)); return + } + while let presented = presenter.presentedViewController { presenter = presented } + let controller = NativeCommunityEntryController(configuration: args) + controller.action = { [weak self] method, payload, completion in + guard let self, self.session == session else { return } + var arguments = payload + arguments["session"] = session + self.channel.invokeMethod(method, arguments: arguments) { value in + guard self.session == session else { return } + completion(value as? [String: Any] ?? ["error": "Could not finish. Try again."]) + } + } + controller.finish = { [weak self] joined in self?.finish(joined) } + let navigation = UINavigationController(rootViewController: controller) + navigation.modalPresentationStyle = .pageSheet + navigation.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light + self.pending = result + self.session = session + self.navigation = navigation + controller.configureDetents() + presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled) + navigation.presentationController?.delegate = self + } + } + + func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { + cancel() + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + complete(false) + } + + private func cancel() { + guard let session else { return } + channel.invokeMethod("cancel", arguments: ["session": session]) + } + + private func finish(_ joined: Bool) { + if !joined { cancel() } + navigation?.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in + self?.complete(joined) + } + } + + private func complete(_ joined: Bool) { + let result = pending + pending = nil + navigation = nil + session = nil + result?(joined) + } +} + +private final class NativeCommunityEntryController: UIViewController, UITextFieldDelegate { + var action: ((String, [String: Any], @escaping ([String: Any]) -> Void) -> Void)? + var finish: ((Bool) -> Void)? + private let configuration: [String: Any] + private let stack = UIStackView() + private let scroll = UIScrollView() + private let input = UITextField() + private let status = UILabel() + private let preview = UILabel() + private var submit: UIButton? + private var buttons: [UIButton] = [] + private var importing = false + private var busy = false + private var generation = 0 + private var confirmed = false + private var measuredHeader: CGFloat = 56 + private let compact = UISheetPresentationController.Detent.Identifier("join") + private var invite: Bool { configuration["invite"] as? Bool == true } + private var dark: Bool { configuration["dark"] as? Bool == true } + private var fieldBackground: UIColor { UIColor(white: dark ? 0.16 : 0.95, alpha: 1) } + private var choices: [[String: String]] { configuration["choices"] as? [[String: String]] ?? [] } + + init(configuration: [String: Any]) { + self.configuration = configuration + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { nil } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = dark ? UIColor(white: 0.11, alpha: 1) : .white + navigationController?.view.backgroundColor = view.backgroundColor + let argb = (configuration["primaryArgb"] as? NSNumber)?.uint32Value ?? 0xFF007AFF + view.tintColor = UIColor(red: CGFloat((argb >> 16) & 255) / 255, + green: CGFloat((argb >> 8) & 255) / 255, blue: CGFloat(argb & 255) / 255, alpha: 1) + navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .close, + primaryAction: UIAction { [weak self] _ in self?.close() }) + if #available(iOS 17.0, *) { view.keyboardLayoutGuide.usesBottomSafeArea = false } + scroll.contentInsetAdjustmentBehavior = .never + scroll.translatesAutoresizingMaskIntoConstraints = false + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.spacing = 12 + view.addSubview(scroll) + scroll.addSubview(stack) + NSLayoutConstraint.activate([ + scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scroll.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), + stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 20), + stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor, constant: -20), + stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -20), + ]) + status.numberOfLines = 0 + status.font = .preferredFont(forTextStyle: .footnote) + status.adjustsFontForContentSizeCategory = true + status.textColor = .secondaryLabel + preview.numberOfLines = 0 + preview.font = .preferredFont(forTextStyle: .footnote) + preview.adjustsFontForContentSizeCategory = true + input.placeholder = "Private key (nsec)" + input.accessibilityLabel = "Private key (nsec)" + input.isSecureTextEntry = true + input.autocorrectionType = .no + input.autocapitalizationType = .none + input.spellCheckingType = .no + input.smartQuotesType = .no + input.smartDashesType = .no + input.smartInsertDeleteType = .no + input.font = .preferredFont(forTextStyle: .body) + input.adjustsFontForContentSizeCategory = true + input.backgroundColor = fieldBackground + input.textColor = dark ? .white : .black + input.layer.cornerRadius = 12 + input.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 1)) + input.leftViewMode = .always + input.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 1)) + input.rightViewMode = .always + input.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true + input.delegate = self + input.addTarget(self, action: #selector(edited), for: .editingChanged) + render() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let header = max(56, view.safeAreaInsets.top, + navigationController?.navigationBar.bounds.height ?? 0) + if abs(header - measuredHeader) > 0.5 { + measuredHeader = header + navigationController?.sheetPresentationController?.invalidateDetents() + } + } + + func configureDetents() { + guard let sheet = navigationController?.sheetPresentationController else { return } + loadViewIfNeeded() + sheet.detents = [.custom(identifier: compact) { [weak self] context in + guard let self else { return min(320, context.maximumDetentValue) } + let width = max(280, self.view.bounds.width - 40) + let content = self.stack.systemLayoutSizeFitting( + CGSize(width: width, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel + ).height + // Include the actual navigation inset. Subtracting the window safe area + // made the compact detent shorter than its visible content viewport. + return min(content + 40 + self.measuredHeader, context.maximumDetentValue) + }, .large()] + sheet.selectedDetentIdentifier = importing ? .large : compact + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + // UIKit owns the corners, drag physics, and transition between detents. + } + + private func render() { + title = importing ? "Import identity" : "Join community" + stack.arrangedSubviews.forEach { stack.removeArrangedSubview($0); $0.removeFromSuperview() } + buttons = [] + submit = nil + status.text = nil + status.isHidden = true + preview.isHidden = !confirmed + if importing { + stack.addArrangedSubview(input) + addButton("Choose nsec file") { [weak self] in self?.chooseFile() } + stack.addArrangedSubview(preview) + submit = addButton(confirmed ? "Import this identity" : "Preview identity", primary: true) { + [weak self] in self?.submitKey() + } + } else { + let name = UILabel() + name.text = configuration["name"] as? String + name.font = .preferredFont(forTextStyle: .headline) + name.adjustsFontForContentSizeCategory = true + name.numberOfLines = 0 + name.textAlignment = .center + name.textColor = dark ? .white : .black + let card = UIView() + card.backgroundColor = fieldBackground + card.layer.cornerRadius = 12 + name.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(name) + NSLayoutConstraint.activate([ + name.topAnchor.constraint(equalTo: card.topAnchor, constant: 32), + name.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -32), + name.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 20), + name.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -20), + ]) + stack.addArrangedSubview(card) + if invite { + addButton("Join community", primary: true) { [weak self] in + self?.request("join", [:]) + } + } else { + for choice in choices { + addButton("\(choice["label"] ?? "Saved identity")\n\(choice["npub"] ?? "")") { [weak self] in + self?.request("join", ["pubkey": choice["pubkey"] ?? ""]) + } + } + addButton("Import a Nostr key") { [weak self] in self?.showImport() } + if choices.isEmpty { + addButton("Create an identity and join", primary: true) { [weak self] in + self?.request("join", ["create": true]) + } + } + } + } + stack.addArrangedSubview(status) + setBusy(false) + } + + @discardableResult + private func addButton(_ title: String, primary: Bool = false, action: @escaping () -> Void) -> UIButton { + let config = onboardingButtonConfiguration(title, primary: primary) + let button = UIButton(configuration: config, primaryAction: UIAction { _ in action() }) + button.titleLabel?.numberOfLines = 0 + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.heightAnchor.constraint(greaterThanOrEqualToConstant: 52).isActive = true + buttons.append(button) + stack.addArrangedSubview(button) + return button + } + + private func showImport() { + importing = true + UIView.performWithoutAnimation { + self.render() + self.view.layoutIfNeeded() + } + navigationController?.sheetPresentationController?.animateChanges { + self.navigationController?.sheetPresentationController?.selectedDetentIdentifier = .large + } + // The field stays ready for a tap; keyboard appearance never competes with expansion. + } + + private func close() { + if invite && busy { return } + generation += 1 + view.endEditing(true) + input.text = nil + preview.text = nil + confirmed = false + action?("cancel", [:]) { _ in } + if importing { + importing = false + UIView.performWithoutAnimation { + self.render() + self.view.layoutIfNeeded() + } + navigationController?.sheetPresentationController?.animateChanges { + self.navigationController?.sheetPresentationController?.selectedDetentIdentifier = self.compact + } + } else { finish?(false) } + } + + private func setBusy(_ value: Bool) { + busy = value + if invite { + navigationController?.isModalInPresentation = value + navigationItem.rightBarButtonItem?.isEnabled = !value + } + buttons.forEach { $0.isEnabled = !value } + input.isEnabled = !value + if value { status.text = "Working…"; status.isHidden = false } + } + + private func request(_ method: String, _ args: [String: Any], completion: (([String: Any]) -> Void)? = nil) { + guard !busy else { return } + generation += 1 + let attempt = generation + setBusy(true) + action?(method, args) { [weak self] result in + guard let self, self.generation == attempt else { return } + self.setBusy(false) + self.status.text = result["error"] as? String + self.status.isHidden = self.status.text == nil + if let error = self.status.text { + UIAccessibility.post(notification: .announcement, argument: error) + } + self.navigationController?.sheetPresentationController?.invalidateDetents() + if result["joined"] as? Bool == true { + self.input.text = nil + self.finish?(true) + } else { completion?(result) } + } + } + + private func showPreview(_ result: [String: Any]) { + guard let npub = result["npub"] as? String else { return } + confirmed = true + preview.isHidden = false + preview.text = "Confirm this is the public identity you want to use:\n\n\(npub)" + submit?.configuration?.title = "Import this identity" + view.endEditing(true) + } + + private func submitKey() { + request(confirmed ? "join" : "preview", ["nsec": input.text ?? ""]) { [weak self] result in + self?.showPreview(result) + } + } + + private func chooseFile() { + view.endEditing(true) + request("file", [:]) { [weak self] result in + guard let self, let text = result["nsec"] as? String else { return } + self.input.text = text + self.showPreview(result) + } + } + + @objc private func edited() { + confirmed = false + preview.text = nil + preview.isHidden = true + status.text = nil + status.isHidden = true + submit?.configuration?.title = "Preview identity" + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + guard let text = textField.text, let range = Range(range, in: text) else { return false } + return text.replacingCharacters(in: range, with: string).utf8.count <= 1024 + } +} + +func onboardingButtonConfiguration(_ title: String, primary: Bool) -> UIButton.Configuration { + var config = primary ? UIButton.Configuration.filled() : .plain() + config.title = title + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in + var updated = attributes + // Match Flutter labelMedium: bundled Inter, 14 points, weight 500. + let inter = NativeAttachmentMenuTypography.font(forTextStyle: .subheadline) + let descriptor = inter.fontDescriptor.addingAttributes([ + UIFontDescriptor.AttributeName(rawValue: kCTFontVariationAttribute as String): + [NSNumber(value: 0x77676874): NSNumber(value: 500)] + ]) + let font = UIFont(descriptor: descriptor, size: 14) + updated.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: font) + return updated + } + // Match the circular StadiumBorder used by the Flutter Continue button. + config.cornerStyle = .fixed + config.background.cornerRadius = 26 + config.contentInsets = NSDirectionalEdgeInsets(top: 14, leading: 20, bottom: 14, trailing: 20) + if !primary { + config.baseForegroundColor = .label + config.background.strokeColor = .separator + config.background.strokeWidth = 1 + } + return config +} diff --git a/mobile/ios/Runner/NativeProfileSetup.swift b/mobile/ios/Runner/NativeProfileSetup.swift new file mode 100644 index 0000000000..6769c73541 --- /dev/null +++ b/mobile/ios/Runner/NativeProfileSetup.swift @@ -0,0 +1,362 @@ +import Flutter +import UIKit +import CoreText + +/// Profile draft presentation; persistence and the existing avatar editor stay in Dart. +final class NativeProfileSetupCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { + private let channel: FlutterMethodChannel + private var navigation: UINavigationController? + private var pending: FlutterResult? + private var session: Int? + private var avatarController: FlutterViewController? + private var avatarEngine: FlutterEngine? + private var avatarChannel: FlutterMethodChannel? + private var transitionCover: UIView? + private var transitionAvatar: UIView? + private var disabledSheetPans: [UIGestureRecognizer] = [] + private var previousInteractivePopEnabled: Bool? + private var previousDetent: UISheetPresentationController.Detent.Identifier? + private var editorAvatarRect: CGRect? + private var avatarDraft: [String: Any]? + private let scheduleEditorWatchdog: (@escaping () -> Void) -> Void + private let startEditorEngine: (FlutterEngine) -> Bool + private let configureEditorEngine: (FlutterEngine) -> Void + + + init(messenger: FlutterBinaryMessenger, scheduleEditorWatchdog: @escaping (@escaping () -> Void) -> Void = { + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: $0) + }, startEditorEngine: @escaping (FlutterEngine) -> Bool = { + $0.run(withEntrypoint: "profileAvatarEditorMain", libraryURI: "package:buzz/features/profile/profile_avatar_sheet_app.dart") + }, configureEditorEngine: @escaping (FlutterEngine) -> Void) { + self.scheduleEditorWatchdog = scheduleEditorWatchdog + self.startEditorEngine = startEditorEngine + self.configureEditorEngine = configureEditorEngine + channel = FlutterMethodChannel(name: "buzz/profile_setup", binaryMessenger: messenger) + super.init() + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { return } + if call.method == "dismiss" { + guard let args = call.arguments as? [String: Any], args["session"] as? Int == self.session else { result(nil); return } + self.finish("cancel"); result(nil); return + } + guard call.method == "present", let args = call.arguments as? [String: Any], + let session = args["session"] as? Int else { result(FlutterMethodNotImplemented); return } + guard self.pending == nil else { result(FlutterError(code: "busy", message: nil, details: nil)); return } + let root = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive }.flatMap(\.windows) + .first(where: \.isKeyWindow)?.rootViewController + guard var presenter = root else { result(FlutterError(code: "unavailable", message: nil, details: nil)); return } + while let next = presenter.presentedViewController { presenter = next } + let controller = NativeProfileSetupController(args: args) + controller.finish = { [weak self] action in self?.finish(action) } + controller.openAvatar = { [weak self, weak controller] in + guard let self, let controller else { return }; self.openAvatar(from: controller, args: args, session: session) + } + controller.action = { [weak self] action, payload, completion in + guard let self, self.session == session else { return } + self.channel.invokeMethod(action, arguments: payload.merging(["session": session]) { _, new in new }) { value in + guard self.session == session else { return } + completion(value as? [String: Any] ?? [:]) + } + } + let nav = UINavigationController(rootViewController: controller) + nav.modalPresentationStyle = .pageSheet + nav.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light + self.pending = result; self.session = session; self.navigation = nav + controller.loadViewIfNeeded() + nav.sheetPresentationController?.detents = [.custom(identifier: .init("profile")) { _ in 430 }, .large()] + nav.sheetPresentationController?.selectedDetentIdentifier = args["expanded"] as? Bool == true ? .large : .init("profile") + nav.sheetPresentationController?.prefersScrollingExpandsWhenScrolledToEdge = false + presenter.present(nav, animated: !UIAccessibility.isReduceMotionEnabled) + nav.presentationController?.delegate = self + } + } + private func openAvatar(from controller: NativeProfileSetupController, args: [String: Any], session: Int) { + guard self.session == session, avatarEngine == nil, let navigation else { return } + previousInteractivePopEnabled = navigation.interactivePopGestureRecognizer?.isEnabled + previousDetent = navigation.sheetPresentationController?.selectedDetentIdentifier + let sourceAvatar = controller.avatarSnapshot(in: navigation.view) + controller.setAvatarHidden(true) + let cover = navigation.view.snapshotView(afterScreenUpdates: true) + controller.setAvatarHidden(false) + controller.view.endEditing(true) + navigation.isModalInPresentation = true + var sheetView: UIView? = navigation.view + while let current = sheetView { + for gesture in current.gestureRecognizers ?? [] where gesture is UIPanGestureRecognizer && gesture.isEnabled { + gesture.isEnabled = false; disabledSheetPans.append(gesture) + } + if current === navigation.presentationController?.containerView { break } + sheetView = current.superview + } + navigation.sheetPresentationController?.animateChanges { + navigation.sheetPresentationController?.detents = [.large()] + navigation.sheetPresentationController?.selectedDetentIdentifier = .large + } + let engine = FlutterEngine(name: "profile-avatar-\(session)", project: nil, allowHeadlessExecution: false) + avatarEngine = engine + guard startEditorEngine(engine) else { + restoreProfileSheet(controller); return + } + let editorChannel = FlutterMethodChannel(name: "buzz/profile_avatar_sheet", binaryMessenger: engine.binaryMessenger) + avatarChannel = editorChannel + var ready = false + var finishing = false + editorChannel.setMethodCallHandler { [weak self, weak controller, weak engine] call, result in + guard let self, let engine, self.session == session, self.avatarEngine === engine, let controller else { result(nil); return } + if call.method == "ready", let rect = call.arguments as? [String: NSNumber], let editor = self.avatarController { + ready = true + let target = CGRect(x: rect["x"]?.doubleValue ?? 0, y: rect["y"]?.doubleValue ?? 0, width: rect["width"]?.doubleValue ?? 220, height: rect["height"]?.doubleValue ?? 220) + self.editorAvatarRect = target + let destination = editor.view.convert(target, to: navigation.view) + self.animateAvatarMotion { + self.transitionCover?.alpha = 0 + self.transitionAvatar?.frame = destination + } completion: { + result(nil) + UIView.animate(withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.08) { + self.transitionAvatar?.alpha = 0 + } completion: { _ in self.clearTransition() } + } + return + } + if call.method == "initial" { result(["dark": args["dark"] ?? false, "draft": self.avatarDraft ?? [:]]); return } + guard call.method == "finish", !finishing else { result(FlutterMethodNotImplemented); return } + finishing = true + let draft = call.arguments as? [String: Any] + func returnToProfile() { + self.clearTransition() + let editor = self.avatarController + let rect = self.editorAvatarRect + let avatarSnapshot = rect.flatMap { editor?.view.resizableSnapshotView(from: $0, afterScreenUpdates: false, withCapInsets: .zero) } + let source = rect.flatMap { editor?.view.convert($0, to: navigation.view) } + let cover = navigation.view.snapshotView(afterScreenUpdates: false) + if let cover, let source { + // Cut the moving avatar out of the outgoing page snapshot. + let mask = CAShapeLayer() + let path = UIBezierPath(rect: navigation.view.bounds) + path.append(UIBezierPath(rect: source)) + mask.path = path.cgPath; mask.fillRule = .evenOdd + cover.layer.mask = mask + } + controller.setAvatarHidden(true) + navigation.setNavigationBarHidden(false, animated: false) + navigation.popViewController(animated: false) + navigation.view.layoutIfNeeded() + let destination = controller.avatarFrame(in: navigation.view) + if let cover { cover.frame = navigation.view.bounds; navigation.view.addSubview(cover); self.transitionCover = cover } + if let avatarSnapshot, let source { + avatarSnapshot.frame = source; navigation.view.addSubview(avatarSnapshot); self.transitionAvatar = avatarSnapshot + } + // Start the sheet resize alongside the shared-avatar return. + navigation.sheetPresentationController?.animateChanges { + self.restoreProfileDetents() + } + self.animateAvatarMotion(returning: true) { + self.transitionCover?.alpha = 0 + self.transitionAvatar?.frame = destination + } completion: { [weak self, weak controller] in + guard let self, self.session == session else { return } + self.restoreProfileSheet(controller) + } + result(nil) + } + if let draft { + self.avatarDraft = draft + self.channel.invokeMethod("avatarDraft", arguments: ["session": session, "draft": draft]) { value in + guard self.session == session else { result(nil); return } + controller.updateAvatar(value as? [String: Any] ?? [:]); returnToProfile() + } + } else { returnToProfile() } + } + GeneratedPluginRegistrant.register(with: engine) + configureEditorEngine(engine) + let messenger = engine.binaryMessenger + engine.registrar(forPlugin: "BuzzNavigationGlassButton")?.register(NavigationGlassButtonFactory(messenger: messenger), withId: "buzz/navigation_glass") + engine.registrar(forPlugin: "BuzzNativeSegmentedControl")?.register(NativeSegmentedControlFactory(messenger: messenger), withId: "buzz/native_segmented_control") + engine.registrar(forPlugin: "BuzzNativeSkinToneControl")?.register(NativeSkinToneControlFactory(messenger: messenger), withId: "buzz/native_skin_tone_control") + let editor = FlutterViewController(engine: engine, nibName: nil, bundle: nil) + avatarController = editor + engine.registrar(forPlugin: "BuzzIosNavigationBar")?.register(IosNavigationBarFactory(messenger: messenger, parent: editor), withId: "buzz/ios_navigation_bar") + editor.view.backgroundColor = controller.view.backgroundColor + navigation.setNavigationBarHidden(true, animated: false) + navigation.pushViewController(editor, animated: false) + if let cover { cover.frame = navigation.view.bounds; navigation.view.addSubview(cover); transitionCover = cover } + if let (snapshot, frame) = sourceAvatar { snapshot.frame = frame; navigation.view.addSubview(snapshot); transitionAvatar = snapshot } + // Recover the whole sheet when Dart never delivers the editor's first frame. + scheduleEditorWatchdog { [weak self, weak engine, weak controller] in + guard let self, let engine, self.session == session, + self.avatarEngine === engine, !ready, !finishing else { return } + navigation.setNavigationBarHidden(false, animated: false) + navigation.popViewController(animated: false) + self.restoreProfileSheet(controller) + } + navigation.interactivePopGestureRecognizer?.isEnabled = false + + } + + // Profile editing uses 200ms easeOutCubic on entrance. Its reversed + // easeInCubic value curve is mirrored below for the visible return motion. + private func animateAvatarMotion(returning: Bool = false, _ animations: @escaping () -> Void, completion: @escaping () -> Void) { + if UIAccessibility.isReduceMotionEnabled { animations(); completion(); return } + let timing = returning + ? UICubicTimingParameters(controlPoint1: CGPoint(x: 0.325, y: 0.81), controlPoint2: CGPoint(x: 0.45, y: 0.945)) + : UICubicTimingParameters(controlPoint1: CGPoint(x: 0.215, y: 0.61), controlPoint2: CGPoint(x: 0.355, y: 1)) + let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: timing) + animator.addAnimations(animations) + animator.addCompletion { _ in completion() } + animator.startAnimation() + } + + private func restoreProfileDetents() { + navigation?.sheetPresentationController?.detents = [.custom(identifier: .init("profile")) { _ in 430 }, .large()] + navigation?.sheetPresentationController?.selectedDetentIdentifier = previousDetent ?? .init("profile") + } + + /// Both failed startup and normal editor return must leave a retryable sheet. + private func restoreProfileSheet(_ controller: NativeProfileSetupController?) { + restoreProfileDetents() + previousDetent = nil + clearTransition() + avatarChannel?.setMethodCallHandler(nil); avatarChannel = nil + avatarEngine?.destroyContext(); avatarEngine = nil + avatarController = nil; editorAvatarRect = nil + navigation?.isModalInPresentation = false + if let enabled = previousInteractivePopEnabled { navigation?.interactivePopGestureRecognizer?.isEnabled = enabled } + previousInteractivePopEnabled = nil + disabledSheetPans.forEach { $0.isEnabled = true }; disabledSheetPans.removeAll() + controller?.setAvatarHidden(false) + controller?.avatarReady() + } + + private func clearTransition() { + transitionCover?.removeFromSuperview(); transitionCover = nil + transitionAvatar?.removeFromSuperview(); transitionAvatar = nil + } + private func finish(_ action: String) { + navigation?.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in self?.complete(action) } + } + private func complete(_ action: String) { + clearTransition() + disabledSheetPans.forEach { $0.isEnabled = true }; disabledSheetPans.removeAll() + let result = pending; pending = nil; navigation = nil; session = nil + avatarChannel?.setMethodCallHandler(nil); avatarChannel = nil; avatarEngine?.destroyContext(); avatarEngine = nil; avatarController = nil; avatarDraft = nil + result?(action) + } + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { complete("skip") } +} + +private final class NativeProfileSetupController: UIViewController, UITextFieldDelegate { + var finish: ((String) -> Void)? + var action: ((String, [String: Any], @escaping ([String: Any]) -> Void) -> Void)? + var openAvatar: (() -> Void)? + private let args: [String: Any] + private let name = UITextField() + private let avatar = UIButton(type: .system) + private let status = UILabel() + private var controls: [UIButton] = [] + private var submit: UIButton! + private var draftName: String + init(args: [String: Any]) { self.args = args; draftName = args["name"] as? String ?? ""; super.init(nibName: nil, bundle: nil) } + required init?(coder: NSCoder) { nil } + override func viewDidLoad() { + super.viewDidLoad() + title = "Set up your profile" + view.backgroundColor = color("pageBackgroundArgb", fallback: .systemBackground) + let argb = (args["primaryArgb"] as? NSNumber)?.uint32Value ?? 0xFF007AFF + view.tintColor = UIColor(red: CGFloat((argb >> 16) & 255) / 255, green: CGFloat((argb >> 8) & 255) / 255, blue: CGFloat(argb & 255) / 255, alpha: 1) + navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .close, primaryAction: UIAction { [weak self] _ in self?.finish?("skip") }) + let stack = UIStackView(); stack.axis = .vertical; stack.spacing = 16; stack.translatesAutoresizingMaskIntoConstraints = false + let scroll = UIScrollView(); scroll.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scroll); scroll.addSubview(stack) + 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) + ]) + let avatarRow = UIView(); avatarRow.heightAnchor.constraint(equalToConstant: 112).isActive = true + avatar.translatesAutoresizingMaskIntoConstraints = false + avatar.accessibilityLabel = "Add profile photo" + updateAvatar(args) + avatar.addAction(UIAction { [weak self] _ in + guard let self else { return }; self.avatar.isEnabled = false; self.openAvatar?() + }, for: .touchUpInside) + avatarRow.addSubview(avatar) + NSLayoutConstraint.activate([avatar.centerXAnchor.constraint(equalTo: avatarRow.centerXAnchor), avatar.topAnchor.constraint(equalTo: avatarRow.topAnchor), avatar.widthAnchor.constraint(equalToConstant: 112), avatar.heightAnchor.constraint(equalToConstant: 112)]) + stack.addArrangedSubview(avatarRow); controls.append(avatar) + name.placeholder = "Display name"; name.accessibilityLabel = "Display name"; name.text = draftName + name.font = .preferredFont(forTextStyle: .body); name.adjustsFontForContentSizeCategory = true + name.backgroundColor = color("containerBackgroundArgb", fallback: .secondarySystemBackground); name.layer.cornerRadius = CGFloat((args["containerCornerRadius"] as? NSNumber)?.doubleValue ?? 22) + name.clearButtonMode = .whileEditing; name.autocapitalizationType = .words; name.returnKeyType = .done + name.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 20, height: 1)); name.leftViewMode = .always + name.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true + name.delegate = self; name.addTarget(self, action: #selector(nameChanged), for: .editingChanged) + stack.addArrangedSubview(name) + status.numberOfLines = 0; status.textColor = .systemRed; status.font = .preferredFont(forTextStyle: .footnote); status.isHidden = true; stack.addArrangedSubview(status) + let spacer = UIView(); spacer.setContentHuggingPriority(.init(1), for: .vertical) + spacer.heightAnchor.constraint(greaterThanOrEqualToConstant: 12).isActive = true; stack.addArrangedSubview(spacer) + submit = UIButton(configuration: onboardingButtonConfiguration("Continue", primary: true), primaryAction: UIAction { [weak self] _ in self?.save() }) + let skip = UIButton(configuration: onboardingButtonConfiguration("Skip", primary: false), primaryAction: UIAction { [weak self] _ in self?.finish?("skip") }) + for button in [submit!, skip] { button.heightAnchor.constraint(greaterThanOrEqualToConstant: 52).isActive = true; stack.addArrangedSubview(button); controls.append(button) } + updateName() + } + @objc private func nameChanged() { draftName = name.text ?? ""; updateName() } + func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder(); return true } + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + guard let current = textField.text, let range = Range(range, in: current) else { return false } + return current.replacingCharacters(in: range, with: string).count <= 100 + } + func setAvatarHidden(_ hidden: Bool) { avatar.isHidden = hidden } + func avatarFrame(in container: UIView) -> CGRect { avatar.convert(avatar.bounds, to: container) } + func avatarSnapshot(in container: UIView) -> (UIView, CGRect)? { + guard let snapshot = avatar.snapshotView(afterScreenUpdates: false) else { return nil } + return (snapshot, avatar.convert(avatar.bounds, to: container)) + } + func avatarReady() { avatar.isEnabled = true } + func updateAvatar(_ args: [String: Any]) { + avatar.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() } + avatar.setImage(nil, for: .normal); avatar.setTitle(nil, for: .normal); avatar.backgroundColor = .clear + if let bytes = args["avatar"] as? FlutterStandardTypedData, let image = UIImage(data: bytes.data) { + avatar.setImage(image.withRenderingMode(.alwaysOriginal), for: .normal); avatar.imageView?.contentMode = .scaleAspectFill + avatar.clipsToBounds = true; avatar.layer.cornerRadius = 56 + } else if let emoji = args["emoji"] as? String { + avatar.setTitle(emoji, for: .normal); avatar.titleLabel?.font = .systemFont(ofSize: 56) + let color = (args["emojiColor"] as? NSNumber)?.uint32Value ?? 0xFFFFFFFF + avatar.backgroundColor = UIColor(red: CGFloat((color >> 16) & 255) / 255, green: CGFloat((color >> 8) & 255) / 255, blue: CGFloat(color & 255) / 255, alpha: 1); avatar.layer.cornerRadius = 56 + } else { + avatar.setImage(tablerPlusImage(), for: .normal) + let ring = CAShapeLayer(); ring.path = UIBezierPath(ovalIn: CGRect(x: 2, y: 2, width: 108, height: 108)).cgPath + ring.strokeColor = UIColor.tertiaryLabel.cgColor; ring.fillColor = UIColor.clear.cgColor; ring.lineWidth = 2; let step = 2 * CGFloat.pi * 54 / 32; ring.lineDashPattern = [NSNumber(value: Double(step * 4 / 9)), NSNumber(value: Double(step * 5 / 9))]; avatar.layer.addSublayer(ring) + } + } + private func tablerPlusImage() -> UIImage? { + guard let frameworks = Bundle.main.privateFrameworksURL, + let provider = CGDataProvider(url: frameworks.appendingPathComponent("App.framework/flutter_assets/assets/fonts/TablerIcons.ttf") as CFURL), + let font = CGFont(provider), let fontName = font.postScriptName else { return nil } + CTFontManagerRegisterGraphicsFont(font, nil) + guard let uiFont = UIFont(name: fontName as String, size: 28), let scalar = UnicodeScalar(0xeb0b) else { return nil } + let glyph = NSAttributedString(string: String(scalar), attributes: [.font: uiFont]) + let size = glyph.size() + return UIGraphicsImageRenderer(size: CGSize(width: 32, height: 32)).image { _ in + glyph.draw(at: CGPoint(x: (32 - size.width) / 2, y: (32 - size.height) / 2)) + }.withRenderingMode(.alwaysTemplate) + } + private func color(_ key: String, fallback: UIColor) -> UIColor { + guard let value = (args[key] as? NSNumber)?.uint32Value else { return fallback } + return UIColor(red: CGFloat((value >> 16) & 255) / 255, green: CGFloat((value >> 8) & 255) / 255, blue: CGFloat(value & 255) / 255, alpha: CGFloat((value >> 24) & 255) / 255) + } + private func updateName() { submit.isEnabled = !draftName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private func save() { + name.isEnabled = false; controls.forEach { $0.isEnabled = false }; navigationController?.isModalInPresentation = true; navigationItem.rightBarButtonItem?.isEnabled = false + submit.configuration?.showsActivityIndicator = true + action?("save", ["name": draftName]) { [weak self] response in + guard let self else { return } + if response["saved"] as? Bool == true { self.finish?("saved"); return } + self.status.text = "Could not save your profile. Try again or skip."; self.status.isHidden = false + UIAccessibility.post(notification: .announcement, argument: self.status.text) + self.name.isEnabled = true; self.controls.forEach { $0.isEnabled = true }; self.updateName(); self.navigationController?.isModalInPresentation = false; self.navigationItem.rightBarButtonItem?.isEnabled = true; self.submit.configuration?.showsActivityIndicator = false + } + } +} diff --git a/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift b/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift new file mode 100644 index 0000000000..4ead63dcad --- /dev/null +++ b/mobile/ios/RunnerTests/ProfileSetupRecoveryTests.swift @@ -0,0 +1,98 @@ +import Flutter +import UIKit +import XCTest + +@testable import Buzz + +final class ProfileSetupRecoveryTests: XCTestCase { + @MainActor + func testStartedEditorWithoutReadyRestoresProfileAndAllowsRetry() async throws { + let messenger = ProfileSetupTestMessenger() + let parent = UIViewController() + let window = UIWindow(frame: UIScreen.main.bounds) + window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + window.rootViewController = parent + window.makeKeyAndVisible() + defer { window.isHidden = true } + var watchdogs: [() -> Void] = [] + weak var engine: FlutterEngine? + let coordinator = NativeProfileSetupCoordinator(messenger: messenger, + scheduleEditorWatchdog: { watchdogs.append($0) }, + startEditorEngine: { + engine = $0 + // Start the normal app entry point: it never sends avatar-editor ready. + return $0.run() + }, configureEditorEngine: { _ in }) + messenger.invoke("present", arguments: ["session": 81]) + let nav = try XCTUnwrap(parent.presentedViewController as? UINavigationController) + if let transition = nav.transitionCoordinator { + await withCheckedContinuation { continuation in + transition.animate(alongsideTransition: nil) { _ in continuation.resume() } + } + } + let profile = try XCTUnwrap(nav.viewControllers.first) + let avatar = try XCTUnwrap(descendants(profile.view).compactMap { $0 as? UIButton } + .first { $0.accessibilityLabel == "Add profile photo" }) + let pan = UIPanGestureRecognizer() + let disabledPan = UIPanGestureRecognizer() + disabledPan.isEnabled = false + nav.view.addGestureRecognizer(pan) + nav.view.addGestureRecognizer(disabledPan) + nav.interactivePopGestureRecognizer?.isEnabled = true + for detent in [UISheetPresentationController.Detent.Identifier("profile"), .large] { + autoreleasepool { + nav.sheetPresentationController?.selectedDetentIdentifier = detent + avatar.sendActions(for: .touchUpInside) + XCTAssertNotNil(engine) + XCTAssertTrue(nav.topViewController is FlutterViewController) + XCTAssertFalse(pan.isEnabled) + XCTAssertFalse(nav.interactivePopGestureRecognizer?.isEnabled ?? true) + XCTAssertTrue(nav.isModalInPresentation) + XCTAssertTrue(nav.isNavigationBarHidden) + if watchdogs.count > 1 { + // An old attempt's watchdog cannot tear down the successful retry. + watchdogs[0]() + XCTAssertTrue(nav.topViewController is FlutterViewController) + XCTAssertNotNil(engine) + } + watchdogs.last?() + XCTAssertTrue(nav.topViewController === profile) + XCTAssertFalse(nav.isNavigationBarHidden) + XCTAssertEqual(nav.sheetPresentationController?.detents.map(\.identifier), [.init("profile"), .large]) + XCTAssertEqual(nav.sheetPresentationController?.selectedDetentIdentifier, detent) + XCTAssertTrue(pan.isEnabled) + XCTAssertFalse(disabledPan.isEnabled) + XCTAssertTrue(nav.interactivePopGestureRecognizer?.isEnabled ?? false) + XCTAssertFalse(nav.isModalInPresentation) + XCTAssertTrue(avatar.isEnabled) + XCTAssertFalse(avatar.isHidden) + } + // UIKit releases popped controllers at the end of its autorelease turn. + let released = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in engine == nil }, object: nil) + await fulfillment(of: [released], timeout: 2) + } + XCTAssertEqual(watchdogs.count, 2) + withExtendedLifetime(coordinator) {} + nav.dismiss(animated: false) + } + + @MainActor + private func descendants(_ view: UIView) -> [UIView] { + view.subviews.flatMap { [$0] + descendants($0) } + } +} + +private final class ProfileSetupTestMessenger: NSObject, FlutterBinaryMessenger { + private var handler: FlutterBinaryMessageHandler? + func send(onChannel channel: String, message: Data?) {} + func send(onChannel channel: String, message: Data?, binaryReply callback: FlutterBinaryReply?) { callback?(nil) } + func setMessageHandlerOnChannel(_ channel: String, binaryMessageHandler handler: FlutterBinaryMessageHandler?) -> FlutterBinaryMessengerConnection { + self.handler = handler + return 1 + } + func cleanUpConnection(_ connection: FlutterBinaryMessengerConnection) { handler = nil } + func invoke(_ method: String, arguments: [String: Any]) { + let codec = FlutterStandardMethodCodec.sharedInstance() + handler?(codec.encode(FlutterMethodCall(methodName: method, arguments: arguments))) { _ in } + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 84530f59b8..cbdaf8e628 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -8,6 +8,59 @@ import XCTest class RunnerTests: XCTestCase { + @MainActor + func testProfileAvatarStartupFailureRestoresSheetAndAllowsRetry() async throws { + let messenger = NavigationTestMessenger() + let parent = UIViewController() + let window = UIWindow(frame: UIScreen.main.bounds) + window.windowScene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + window.rootViewController = parent + window.makeKeyAndVisible() + defer { window.isHidden = true } + var starts = 0 + var navigation: UINavigationController? + let pan = UIPanGestureRecognizer() + let alreadyDisabled = UIPanGestureRecognizer() + alreadyDisabled.isEnabled = false + let coordinator = NativeProfileSetupCoordinator(messenger: messenger, startEditorEngine: { _ in + starts += 1 + XCTAssertEqual(navigation?.sheetPresentationController?.selectedDetentIdentifier, .large) + XCTAssertTrue(navigation?.isModalInPresentation == true) + XCTAssertFalse(pan.isEnabled) + return false + }, configureEditorEngine: { _ in + XCTFail("Failed startup must not configure plugins") + }) + messenger.invoke("present", arguments: ["session": 71]) { _ in } + let nav = try XCTUnwrap(parent.presentedViewController as? UINavigationController) + navigation = nav + if let transition = nav.transitionCoordinator { + await withCheckedContinuation { continuation in + transition.animate(alongsideTransition: nil) { _ in continuation.resume() } + } + } + nav.view.addGestureRecognizer(pan) + nav.view.addGestureRecognizer(alreadyDisabled) + let profile = try XCTUnwrap(nav.viewControllers.first) + let avatar = try XCTUnwrap(descendants(of: profile.view).compactMap { $0 as? UIButton } + .first { $0.accessibilityLabel == "Add profile photo" }) + for detent in [UISheetPresentationController.Detent.Identifier("profile"), .large] { + nav.sheetPresentationController?.selectedDetentIdentifier = detent + avatar.sendActions(for: .touchUpInside) + XCTAssertEqual(nav.sheetPresentationController?.detents.map(\.identifier), [.init("profile"), .large]) + XCTAssertEqual(nav.sheetPresentationController?.selectedDetentIdentifier, detent) + XCTAssertTrue(pan.isEnabled) + XCTAssertFalse(alreadyDisabled.isEnabled) + XCTAssertFalse(nav.isModalInPresentation) + XCTAssertTrue(avatar.isEnabled) + XCTAssertFalse(avatar.isHidden) + XCTAssertTrue(nav.topViewController === profile) + } + XCTAssertEqual(starts, 2) + withExtendedLifetime(coordinator) {} + nav.dismiss(animated: false) + } + @MainActor private func descendants(of view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + descendants(of: $0) } diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart index f110220de8..8039ad82c7 100644 --- a/mobile/lib/features/invites/invite_join_sheet.dart +++ b/mobile/lib/features/invites/invite_join_sheet.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import '../../shared/identity_transfer/native_community_entry.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; @@ -8,7 +10,36 @@ import '../../shared/widgets/modal_presentation.dart'; import '../pairing/pairing_page.dart'; import 'invite_join_provider.dart'; -Future showInviteJoinSheet(BuildContext context) { +Future showInviteJoinSheet(BuildContext context) async { + final container = ProviderScope.containerOf(context); + final state = container.read(inviteJoinProvider); + if (defaultTargetPlatform == TargetPlatform.iOS && + state.status == InviteJoinStatus.confirming && + !state.isStarterSetupRecovery) { + final joined = await NativeCommunityEntry.show( + name: state.host ?? 'Unknown community', + dark: Theme.of(context).brightness == Brightness.dark, + primaryArgb: Theme.of(context).colorScheme.primary.toARGB32(), + choices: const [], + connect: (_, _, _, _) async {}, + confirmInvite: () async { + final before = container.read(inviteJoinProvider); + if (before.requiresFreshInvite) { + return { + 'error': before.errorMessage ?? 'Open a fresh invite to continue.', + }; + } + await container.read(inviteJoinProvider.notifier).confirmJoin(); + final result = container.read(inviteJoinProvider); + return result.status == InviteJoinStatus.success + ? {'joined': true} + : {'error': result.errorMessage ?? 'Could not join. Try again.'}; + }, + ); + if (joined == false) return false; + if (!context.mounted) return joined; + // Preserve the existing success screen and starter-channel destination. + } return showBuzzModalBottomSheet( context: context, isScrollControlled: true, diff --git a/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart index 8e92841a49..d1f2dc1e29 100644 --- a/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart +++ b/mobile/lib/features/onboarding/self_hosted_entry_sheet.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -6,9 +7,11 @@ import '../../shared/auth/auth.dart'; import '../../shared/hosted/device_identity_source.dart'; import '../../shared/hosted/self_hosted_entry.dart'; import '../../shared/identity_transfer/nsec_import_sheet.dart'; +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/buzz_titled_sheet_layout.dart'; /// Local preparation seam; the production service validates and retains keys. final selfHostedEntryProvider = Provider( @@ -20,16 +23,65 @@ Future showSelfHostedEntry( BuildContext context, { required String relay, required String name, -}) async => - await showBuzzModalBottomSheet( - context: context, - title: 'Join community', - isScrollControlled: true, - useRootNavigator: true, - useSafeArea: true, - builder: (_) => SelfHostedEntrySheet(relay: relay, name: name), - ) ?? - false; +}) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + final container = ProviderScope.containerOf(context); + final service = container.read(selfHostedEntryProvider); + final auth = container.read(authProvider.notifier); + final storage = container.read(communityStorageProvider); + final dark = context.theme.brightness == Brightness.dark; + final primary = context.colors.primary.toARGB32(); + // Keep the existing Flutter error state if saved identities are unreadable. + List? choices; + try { + final inventory = await service.choices(); + if (inventory.complete) choices = inventory.choices; + } catch (_) {} + if (!context.mounted) return false; + if (choices != null) { + final result = await NativeCommunityEntry.show( + name: name.isEmpty ? Uri.parse(relay).host : name, + dark: dark, + primaryArgb: primary, + choices: choices, + connect: (pubkey, nsec, create, current) async { + final community = await service.prepare( + relay: relay, + name: name, + savedPubkey: pubkey, + nsec: nsec, + create: create, + isCurrent: current, + ); + if (!current()) return; + await auth.authenticateWithCommunity( + community, + shouldContinue: current, + ); + final saved = await storage.loadAll(); + if (saved.any( + (item) => item.id == community.id && item.nsec == community.nsec, + )) { + await service.complete(community); + } + }, + ); + if (result != null) return result; + } + if (!context.mounted) return false; + } + return await showBuzzModalBottomSheet( + context: context, + showCloseButton: false, + isScrollControlled: true, + useRootNavigator: true, + useSafeArea: true, + // This sheet owns equal content gutters on all three lower edges. + padBottom: false, + builder: (_) => SelfHostedEntrySheet(relay: relay, name: name), + ) ?? + false; +} class SelfHostedEntrySheet extends HookConsumerWidget { const SelfHostedEntrySheet({ @@ -45,6 +97,9 @@ class SelfHostedEntrySheet extends HookConsumerWidget { final service = ref.watch(selfHostedEntryProvider); final choices = useFuture(useMemoized(service.choices)); final busy = useState(false); + final importing = useState(false); + final importFocusReady = useState(false); + final generation = useRef(0); final error = useState(null); bool mounted() => context.mounted; Future connect({ @@ -54,6 +109,8 @@ class SelfHostedEntrySheet extends HookConsumerWidget { bool close = true, }) async { if (busy.value) return; + final attempt = ++generation.value; + bool current() => mounted() && generation.value == attempt; busy.value = true; error.value = null; final storage = ref.read(communityStorageProvider); @@ -64,12 +121,12 @@ class SelfHostedEntrySheet extends HookConsumerWidget { savedPubkey: pubkey, nsec: nsec, create: create, - isCurrent: mounted, + isCurrent: current, ); - if (!mounted()) return; + if (!current()) return; await ref .read(authProvider.notifier) - .authenticateWithCommunity(community, shouldContinue: mounted); + .authenticateWithCommunity(community, shouldContinue: current); // Admission can return early after cancellation. Check the durable // record before retiring the only retry copy of a generated key. final saved = await storage.loadAll(); @@ -78,15 +135,17 @@ class SelfHostedEntrySheet extends HookConsumerWidget { )) { await service.complete(community); } - if (close && context.mounted) Navigator.of(context).pop(true); + if (close && current() && context.mounted) { + Navigator.of(context).pop(true); + } } catch (_) { - if (mounted()) { + if (current()) { error.value = 'Could not join. Check this community accepts your identity, then try again. Your saved keys have been kept.'; } rethrow; } finally { - if (mounted()) busy.value = false; + if (current()) busy.value = false; } } @@ -96,18 +155,57 @@ class SelfHostedEntrySheet extends HookConsumerWidget { } catch (_) {} } + Widget button({ + required VoidCallback? onPressed, + required Widget child, + bool primary = false, + }) { + return primary + ? FilledButton( + style: FilledButton.styleFrom( + backgroundColor: context.colors.primary, + foregroundColor: context.colors.onPrimary, + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ), + onPressed: onPressed, + child: child, + ) + : OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: context.colors.onSurface, + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ), + onPressed: onPressed, + child: child, + ); + } + final saved = choices.data?.choices ?? const []; - return Padding( + final joinContent = Padding( padding: const EdgeInsets.all(Grid.gutter), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(Uri.parse(relay).host, style: context.textTheme.titleMedium), - const SizedBox(height: Grid.sm), - const Text( - 'Choose the identity to use in this community. Your key stays on this device.', + Container( + padding: const EdgeInsets.all(Grid.sm), + decoration: BoxDecoration( + color: context.theme.brightness == Brightness.dark + ? const Color(0xFF233039) + : Colors.white.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: context.colors.primary.withValues(alpha: 0.15), + ), + ), + child: Text( + name.isEmpty ? Uri.parse(relay).host : name, + textAlign: TextAlign.center, + style: context.textTheme.titleMedium, + ), ), const SizedBox(height: Grid.sm), if (choices.hasError || choices.data?.complete == false) @@ -115,7 +213,7 @@ class SelfHostedEntrySheet extends HookConsumerWidget { 'Some saved identities could not be read. You can use an available identity or import a key.', ), for (final identity in saved) - OutlinedButton( + button( onPressed: busy.value ? null : () => select(pubkey: identity.pubkey), @@ -127,22 +225,18 @@ class SelfHostedEntrySheet extends HookConsumerWidget { ), ), if (choices.connectionState == ConnectionState.done) ...[ - OutlinedButton( + button( onPressed: busy.value ? null - : () async { - final imported = await showNsecImportSheet( - context, - onImport: (nsec) => connect(nsec: nsec, close: false), - ); - if (imported && context.mounted) { - Navigator.of(context).pop(true); - } + : () { + importFocusReady.value = false; + importing.value = true; }, child: const Text('Import a Nostr key'), ), if (saved.isEmpty && choices.data?.complete == true) - FilledButton( + button( + primary: true, onPressed: busy.value ? null : () => select(create: true), child: const Text('Create an identity and join'), ), @@ -162,5 +256,39 @@ class SelfHostedEntrySheet extends HookConsumerWidget { ), ), ); + return AnimatedSize( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + onEnd: () { + if (context.mounted && importing.value) importFocusReady.value = true; + }, + child: BuzzTitledSheetLayout( + title: importing.value ? 'Import identity' : 'Join community', + onClose: importing.value + ? () { + generation.value++; + FocusManager.instance.primaryFocus?.unfocus(); + busy.value = false; + error.value = null; + importFocusReady.value = false; + importing.value = false; + } + : null, + child: importing.value + ? SizedBox( + height: MediaQuery.sizeOf(context).height * 0.8, + child: NsecImportSheet( + autofocus: + importFocusReady.value || + MediaQuery.disableAnimationsOf(context), + onImport: (nsec) => connect(nsec: nsec, close: false), + ), + ) + : joinContent, + ), + ); } } diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index acf0e1d791..ad3a12f5f8 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -8,6 +8,8 @@ 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'; @@ -34,6 +36,7 @@ part 'pairing_page/onboarding_colors.dart'; part 'pairing_page/onboarding_glass_button.dart'; part 'pairing_page/pairing_welcome_view.dart'; part 'pairing_page/community_link_form.dart'; +part 'pairing_page/onboarding_connection_view.dart'; part 'pairing_page/sas_verification_view.dart'; part 'pairing_page/pairing_code_entry.dart'; part 'pairing_page/pairing_error_shake.dart'; @@ -46,6 +49,7 @@ class PairingPage extends HookConsumerWidget { final VoidCallback? onAlreadyUseBuzz; final VoidCallback? onWelcomeBack; final bool connectionLanding; + final bool showConnectionOptions; final bool existingUser; final bool enteringCommunityLink; final bool creatingCommunity; @@ -56,6 +60,7 @@ class PairingPage extends HookConsumerWidget { final String? accountStatus; final Widget? setupContent; final String builderlabLabel; + final bool builderlabBusy; const PairingPage({ super.key, @@ -64,6 +69,7 @@ class PairingPage extends HookConsumerWidget { this.onAlreadyUseBuzz, this.onWelcomeBack, this.connectionLanding = false, + this.showConnectionOptions = true, this.existingUser = false, this.enteringCommunityLink = false, this.creatingCommunity = false, @@ -74,15 +80,20 @@ class PairingPage extends HookConsumerWidget { this.accountStatus, this.setupContent, this.builderlabLabel = 'Connect to Builderlab', + this.builderlabBusy = false, }); @override Widget build(BuildContext context, WidgetRef ref) { + final hostedFlow = useListenable( + HostedEnvironment.enabled ? ref.watch(hostedFlowProvider) : null, + ); final pairingState = ref.watch(pairingProvider); final enrolledBiometrics = ref.watch(enrolledBiometricsProvider); final codeController = useTextEditingController(); final fallbackScannerVisible = useState(false); final pairingCodeExpanded = useState(false); + final optionsExpanded = useState(false); final isBusy = pairingState.status == PairingStatus.connecting || pairingState.status == PairingStatus.transferring || @@ -225,6 +236,8 @@ class PairingPage extends HookConsumerWidget { value: onboardingSystemOverlayStyle, child: _OnboardingBackground( child: Scaffold( + resizeToAvoidBottomInset: + hostedFlow?.stage != HostedFlowStage.profile, backgroundColor: Colors.transparent, body: SafeArea( child: Padding( @@ -261,13 +274,17 @@ class PairingPage extends HookConsumerWidget { value: onboardingSystemOverlayStyle, child: _OnboardingBackground( child: Scaffold( + resizeToAvoidBottomInset: + hostedFlow?.stage != HostedFlowStage.profile, backgroundColor: Colors.transparent, appBar: pairingAppBar, body: SafeArea( child: _PairingWelcomeView( + optionsExpanded: optionsExpanded, onAlreadyUseBuzz: onAlreadyUseBuzz, onWelcomeBack: onWelcomeBack, connectionLanding: connectionLanding, + showConnectionOptions: showConnectionOptions, existingUser: existingUser, enteringCommunityLink: enteringCommunityLink, creatingCommunity: creatingCommunity, @@ -282,6 +299,7 @@ class PairingPage extends HookConsumerWidget { accountStatus: accountStatus, setupContent: setupContent, builderlabLabel: builderlabLabel, + builderlabBusy: builderlabBusy, codeController: codeController, isBusy: isBusy, pairingCodeExpanded: pairingCodeExpanded.value, 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 39f0fd5171..cb436f09f6 100644 --- a/mobile/lib/features/pairing/pairing_page/community_link_form.dart +++ b/mobile/lib/features/pairing/pairing_page/community_link_form.dart @@ -5,16 +5,31 @@ class _CommunityLinkForm extends HookConsumerWidget { required this.onOpen, required this.onBack, this.onCreate, + this.controller, + this.focusNode, + this.showBack = true, + this.fullPage = false, + this.autofocus = true, + this.showHeading = true, + this.layoutBuilder, }); final ValueChanged? onCreate; + final TextEditingController? controller; + final FocusNode? focusNode; + final bool showBack; + final bool fullPage; + final bool autofocus; + final bool showHeading; + final Widget Function(Widget fields, Widget action)? layoutBuilder; final ValueChanged onOpen; final VoidCallback? onBack; @override Widget build(BuildContext context, WidgetRef ref) { - final controller = useTextEditingController(); + final localController = useTextEditingController(); + final controller = this.controller ?? localController; final error = useState(null); final flow = HostedEnvironment.enabled @@ -56,9 +71,62 @@ class _CommunityLinkForm extends HookConsumerWidget { onOpen(link); } - return Column( - mainAxisSize: MainAxisSize.min, - children: [ + void changed(String _) { + error.value = null; + if (failed) unawaited(flow.leave()); + } + + final problem = + error.value ?? (failed ? hostedFlowProblemMessage(flow.problem) : null); + final iosInput = fullPage && defaultTargetPlatform == TargetPlatform.iOS; + + final fields = [ + if (fullPage && showHeading) ...[ + Text( + 'Enter a community link', + style: context.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Grid.xs), + Text( + 'Paste an invite link or community address.', + style: context.textTheme.titleLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.lg), + ], + if (iosInput) ...[ + CupertinoTextField( + key: const Key('community-link-input'), + 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(), + ), + if (problem != null) + Padding( + padding: const EdgeInsets.only(top: Grid.xs), + child: Text(problem, style: TextStyle(color: context.colors.error)), + ), + ] else TextField( key: Key( onCreate != null @@ -66,14 +134,18 @@ class _CommunityLinkForm extends HookConsumerWidget { : 'community-link-input', ), controller: controller, + focusNode: focusNode, + autofocus: fullPage && autofocus, enabled: !busy, keyboardType: TextInputType.url, textInputAction: TextInputAction.go, autocorrect: false, enableSuggestions: false, - style: context.textTheme.bodyMedium?.copyWith( - color: context._onboardingInk, - ), + style: + (fullPage + ? context.textTheme.titleLarge + : context.textTheme.bodyMedium) + ?.copyWith(color: context._onboardingInk), cursorColor: context._onboardingInk, decoration: InputDecoration( labelText: onCreate != null @@ -98,27 +170,64 @@ class _CommunityLinkForm extends HookConsumerWidget { borderSide: BorderSide(color: context._onboardingInk), ), ), - onChanged: (_) { - error.value = null; - if (failed) unawaited(flow.leave()); - }, + onChanged: changed, onSubmitted: (_) => submit(), ), + ]; + final action = FilledButton( + style: fullPage + ? FilledButton.styleFrom( + backgroundColor: layoutBuilder == null + ? context.colors.primary + : Colors.transparent, + shadowColor: Colors.transparent, + animationDuration: Duration.zero, + foregroundColor: context.colors.onPrimary, + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ) + : context._onboardingGlassButtonStyle, + onPressed: busy || (failed && !flow.canRetry) ? null : submit, + child: Text(busy ? 'Connecting…' : 'Continue'), + ); + if (layoutBuilder != null) { + return layoutBuilder!( + SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: fields, + ), + ), + action, + ); + } + return Column( + mainAxisSize: fullPage ? MainAxisSize.max : MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (fullPage) + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: fields, + ), + ), + ) + else + ...fields, const SizedBox(height: Grid.xs), - FilledButton( - style: context._onboardingGlassButtonStyle, - onPressed: busy || (failed && !flow.canRetry) ? null : submit, - child: Text(busy ? 'Connecting…' : 'Continue'), - ), + action, const SizedBox(height: Grid.xxs), - TextButton( - style: context._onboardingGhostButtonStyle, - onPressed: () { - unawaited(flow?.leave()); - onBack?.call(); - }, - child: const Text('Back'), - ), + if (showBack) + TextButton( + style: context._onboardingGhostButtonStyle, + onPressed: () { + unawaited(flow?.leave()); + onBack?.call(); + }, + child: const Text('Back'), + ), ], ); } diff --git a/mobile/lib/features/pairing/pairing_page/onboarding_colors.dart b/mobile/lib/features/pairing/pairing_page/onboarding_colors.dart index 27bda3b5a7..0d1c69fc76 100644 --- a/mobile/lib/features/pairing/pairing_page/onboarding_colors.dart +++ b/mobile/lib/features/pairing/pairing_page/onboarding_colors.dart @@ -20,7 +20,7 @@ extension _OnboardingColors on BuildContext { ); ButtonStyle get _onboardingButtonStyle => FilledButton.styleFrom( - minimumSize: const Size(0, 48), + minimumSize: const Size(0, 52), padding: const EdgeInsets.symmetric( horizontal: Grid.lg, vertical: Grid.twelve, @@ -33,7 +33,7 @@ extension _OnboardingColors on BuildContext { ); ButtonStyle get _onboardingSecondaryButtonStyle => TextButton.styleFrom( - minimumSize: const Size(0, 44), + minimumSize: const Size(0, 52), padding: const EdgeInsets.symmetric( horizontal: Grid.md, vertical: Grid.xxs, diff --git a/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart b/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart new file mode 100644 index 0000000000..bf21893793 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_page/onboarding_connection_view.dart @@ -0,0 +1,500 @@ +part of '../pairing_page.dart'; + +/// Keeps the selected community surface in place as it opens into a form. +class _OnboardingConnectionView extends HookWidget { + const _OnboardingConnectionView({ + required this.enteringLink, + required this.onCommunityLink, + required this.onBack, + required this.onOpenCommunity, + required this.onScan, + required this.onPairing, + }); + + final bool enteringLink; + final VoidCallback? onCommunityLink; + final VoidCallback? onBack; + final ValueChanged onOpenCommunity; + final VoidCallback? onScan; + final VoidCallback? onPairing; + + double _textProgress(double progress, bool opening) => + opening ? ((progress - 0.2) / 0.8).clamp(0.0, 1.0) : progress; + + @override + 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 + ? Duration.zero + : Duration(milliseconds: ios ? 200 : 150); + final controller = useTextEditingController(); + final focus = useFocusNode(); + // Keep the return destination stable while keyboard insets change. + // 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]); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: defaultTargetPlatform == TargetPlatform.iOS + ? IosGlassNavigationButton( + key: const Key('community-link-back'), + icon: IosGlassNavigationIcon.back, + semanticLabel: 'Back', + foregroundColor: context.colors.onSurface, + onPressed: onBack, + ) + : BackButton( + key: const Key('community-link-back'), + color: context.colors.onSurface, + onPressed: onBack, + ), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + if (!enteringLink && + MediaQuery.viewInsetsOf(context).bottom == 0) { + restingHeight[0] ??= constraints.maxHeight; + } + return TweenAnimationBuilder( + tween: Tween( + end: enteringLink && (!ios || keyboardReady.value) ? 1 : 0, + ), + duration: duration, + curve: ios + ? Curves.easeOutCubic + : enteringLink + ? Curves.easeOutQuad + : Curves.easeOutCubic.flipped, + builder: (context, progress, _) => _CommunityLinkForm( + controller: controller, + focusNode: focus, + showBack: false, + fullPage: true, + autofocus: false, + showHeading: false, + onOpen: onOpenCommunity, + onBack: onBack, + layoutBuilder: (fields, action) => CustomMultiChildLayout( + delegate: _ConnectionLayout( + progress: progress, + opening: staggerEntrance, + restingHeight: restingHeight[0], + ), + children: [ + LayoutId( + id: 'heading', + child: SingleChildScrollView( + child: Stack( + children: [ + _ConnectionMotionLayer( + progress: + 1 - + _textProgress(progress, staggerEntrance), + active: !enteringLink, + maintainSize: true, + child: const _ConnectionHeading( + title: 'Connect to a community', + subtitle: + 'Scan a QR code or enter a community link to continue.', + ), + ), + _ConnectionMotionLayer( + progress: _textProgress( + progress, + staggerEntrance, + ), + active: enteringLink, + maintainSize: true, + child: const _ConnectionHeading( + title: 'Enter a community link', + subtitle: + 'Paste an invite link or community address.', + ), + ), + ], + ), + ), + ), + LayoutId( + id: 'scan', + child: _ConnectionMotionLayer( + key: const Key('connection-options-motion'), + progress: 1 - progress, + active: !enteringLink, + maintainSize: true, + child: _OnboardingChoiceContainer( + title: 'Scan a QR code', + icon: BuzzIcons.qrcode, + subtitle: + 'Scan a community or desktop pairing code.', + onTap: onScan, + onIconDoubleTap: onPairing, + ), + ), + ), + LayoutId( + id: 'surface', + child: DecoratedBox( + decoration: BoxDecoration( + color: Color.lerp( + context.colors.surface, + context.colors.primary, + progress, + ), + borderRadius: BorderRadius.circular( + Radii.container + + (26 - Radii.container) * progress, + ), + ), + ), + ), + LayoutId( + id: 'link', + child: _ConnectionMotionLayer( + progress: 1 - progress, + active: !enteringLink, + maintainSize: true, + child: _OnboardingChoiceContainer( + title: 'Enter a community link', + transparent: true, + icon: BuzzIcons.link, + subtitle: + 'Join a community with its address or invite.', + onTap: onCommunityLink, + ), + ), + ), + LayoutId( + id: 'action', + child: _ConnectionMotionLayer( + progress: progress, + active: enteringLink, + maintainSize: true, + child: action, + ), + ), + LayoutId( + id: 'form', + child: _ConnectionMotionLayer( + key: const Key('community-form-motion'), + maintainSize: true, + progress: staggerEntrance + ? ((progress - 0.4) / 0.6).clamp(0.0, 1.0) + : (progress / 0.8).clamp(0.0, 1.0), + active: enteringLink, + child: fields, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +/// Measures the cards so the resting heading sits directly above them, +/// independent of text wrapping. Both states share the same moving heading. +class _ConnectionLayout extends MultiChildLayoutDelegate { + _ConnectionLayout({ + required this.progress, + required this.opening, + this.restingHeight, + }); + + final bool opening; + + final double? restingHeight; + + final double progress; + + @override + void performLayout(Size size) { + final baselineHeight = restingHeight ?? size.height; + final heading = layoutChild( + 'heading', + BoxConstraints( + minWidth: size.width, + maxWidth: size.width, + maxHeight: size.height / 3, + ), + ); + final cardConstraints = BoxConstraints( + minWidth: size.width, + maxWidth: size.width, + maxHeight: baselineHeight, + ); + final scan = layoutChild('scan', cardConstraints); + final link = layoutChild('link', cardConstraints); + final restingTop = + (baselineHeight - + Grid.sm - + link.height - + Grid.xs - + scan.height - + Grid.sm - + heading.height) + .clamp(0.0, baselineHeight); + final expandedTop = Grid.sm.toDouble(); + final movement = opening ? (progress / 0.8).clamp(0.0, 1.0) : progress; + final headingTop = restingTop + (expandedTop - restingTop) * movement; + positionChild('heading', Offset(0, headingTop)); + positionChild( + 'scan', + Offset( + 0, + baselineHeight - + Grid.sm - + link.height - + Grid.xs - + scan.height + + Grid.sm * progress, + ), + ); + final linkTop = baselineHeight - Grid.sm - link.height; + final actionTop = size.height - Grid.sm - 52; + final sharedTop = linkTop + (actionTop - linkTop) * progress; + positionChild('link', Offset(0, sharedTop)); + layoutChild( + 'surface', + BoxConstraints.tight( + Size(size.width, link.height + (52 - link.height) * progress), + ), + ); + positionChild('surface', Offset(0, sharedTop)); + layoutChild( + 'action', + BoxConstraints.tight( + Size(size.width, link.height + (52 - link.height) * progress), + ), + ); + positionChild('action', Offset(0, sharedTop)); + if (hasChild('form')) { + final formTop = expandedTop + heading.height + Grid.sm; + layoutChild( + 'form', + BoxConstraints.tight( + Size( + size.width, + (size.height - formTop - Grid.sm - 52 - Grid.xs).clamp( + 0.0, + size.height, + ), + ), + ), + ); + positionChild('form', Offset(0, headingTop + heading.height + Grid.sm)); + } + } + + @override + bool shouldRelayout(_ConnectionLayout oldDelegate) => + oldDelegate.progress != progress || + oldDelegate.opening != opening || + oldDelegate.restingHeight != restingHeight; +} + +class _ConnectionHeading extends StatelessWidget { + const _ConnectionHeading({required this.title, required this.subtitle}); + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: context.textTheme.headlineSmall), + const SizedBox(height: Grid.xs), + Text( + subtitle, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ); +} + +class _ConnectionMotionLayer extends StatelessWidget { + const _ConnectionMotionLayer({ + super.key, + required this.progress, + required this.active, + required this.child, + this.maintainSize = false, + }); + final double progress; + final bool active; + final Widget child; + final bool maintainSize; + + @override + Widget build(BuildContext context) => Visibility( + visible: active || progress > 0, + maintainState: true, + maintainAnimation: true, + maintainSize: maintainSize, + child: IgnorePointer( + ignoring: !active, + child: ExcludeFocus( + excluding: !active, + child: ExcludeSemantics( + excluding: !active, + child: Opacity(opacity: progress, child: child), + ), + ), + ), + ); +} + +class _OnboardingChoiceContainer extends StatelessWidget { + const _OnboardingChoiceContainer({ + required this.title, + required this.subtitle, + required this.icon, + this.onTap, + this.onIconDoubleTap, + this.transparent = false, + }); + + final String title; + final String subtitle; + final IconData icon; + final bool transparent; + final VoidCallback? onTap; + final VoidCallback? onIconDoubleTap; + + @override + Widget build(BuildContext context) => Semantics( + button: onTap != null, + label: title, + hint: subtitle, + onTap: onTap, + child: ExcludeSemantics( + excluding: true, + child: Material( + // White surface cards over the onboarding artwork. + color: transparent ? Colors.transparent : context.colors.surface, + borderRadius: BorderRadius.circular(Radii.container), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + excludeFromSemantics: true, + child: Padding( + padding: const EdgeInsets.all(Grid.gutter), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: GestureDetector( + key: onIconDoubleTap == null + ? null + : const Key('developer-pairing-trigger'), + excludeFromSemantics: true, + onDoubleTap: onIconDoubleTap, + child: SizedBox( + width: Grid.sm, + height: Grid.sm, + child: Icon( + icon, + size: Grid.sm, + color: context.colors.onSurface, + ), + ), + ), + ), + const SizedBox(width: Grid.xs), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Align the visible glyphs with the icon, accounting + // for the title font's leading above its cap height. + Transform.translate( + offset: const Offset(0, -2), + child: Text( + title, + style: context.textTheme.titleSmall?.copyWith( + color: context.colors.onSurface, + ), + ), + ), + const SizedBox(height: Grid.half), + Text( + subtitle, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart index 9369855aa5..63bc191344 100644 --- a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart +++ b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart @@ -1,6 +1,7 @@ part of '../pairing_page.dart'; -class _PairingWelcomeView extends StatelessWidget { +class _PairingWelcomeView extends HookWidget { + final ValueNotifier optionsExpanded; final TextEditingController codeController; final bool isBusy; final bool pairingCodeExpanded; @@ -11,6 +12,7 @@ class _PairingWelcomeView extends StatelessWidget { final VoidCallback? onAlreadyUseBuzz; final VoidCallback? onWelcomeBack; final bool connectionLanding; + final bool showConnectionOptions; final bool existingUser; final bool enteringCommunityLink; final bool creatingCommunity; @@ -21,9 +23,11 @@ class _PairingWelcomeView extends StatelessWidget { final String? accountStatus; final Widget? setupContent; final String builderlabLabel; + final bool builderlabBusy; final ValueChanged onOpenCommunity; const _PairingWelcomeView({ + required this.optionsExpanded, required this.codeController, required this.isBusy, required this.pairingCodeExpanded, @@ -35,6 +39,7 @@ class _PairingWelcomeView extends StatelessWidget { this.onAlreadyUseBuzz, this.onWelcomeBack, this.connectionLanding = false, + this.showConnectionOptions = true, this.existingUser = false, this.enteringCommunityLink = false, this.creatingCommunity = false, @@ -45,6 +50,7 @@ class _PairingWelcomeView extends StatelessWidget { this.accountStatus, this.setupContent, this.builderlabLabel = 'Connect to Builderlab', + this.builderlabBusy = false, }); @override @@ -52,7 +58,7 @@ class _PairingWelcomeView extends StatelessWidget { final reducedMotion = MediaQuery.disableAnimationsOf(context); final revealDuration = reducedMotion ? Duration.zero - : const Duration(milliseconds: 220); + : const Duration(milliseconds: 150); final selectedPage = creatingCommunity ? 4 @@ -61,7 +67,33 @@ class _PairingWelcomeView extends StatelessWidget { : existingUser ? 2 : 1; - Widget actions(int page) => page == 3 || page == 4 + List connectionActions() => [ + const SizedBox(height: Grid.xxs), + FilledButton( + style: context._onboardingGlassButtonStyle, + onPressed: onCommunityLink, + child: const Text('Enter a community link'), + ), + const SizedBox(height: Grid.xxs), + GestureDetector( + key: const Key('developer-pairing-trigger'), + excludeFromSemantics: true, + onDoubleTap: isBusy ? null : onAlreadyUseBuzz, + child: FilledButton( + style: context._onboardingGlassButtonStyle, + onPressed: isBusy ? null : onScan, + child: const Text('Scan a QR code'), + ), + ), + ]; + final showingOptions = + optionsExpanded.value && + !existingUser && + setupContent == null && + !showConnectionOptions; + Widget actions(int page) => page == 3 && optionsExpanded.value + ? const SizedBox.shrink() + : page == 3 || page == 4 ? _CommunityLinkForm( onOpen: onOpenCommunity, onBack: onWelcomeBack, @@ -92,39 +124,36 @@ class _PairingWelcomeView extends StatelessWidget { const SizedBox(height: Grid.xs), ], if (page == 1) ...[ - if (HostedEnvironment.enabled) ...[ + if (HostedEnvironment.enabled) FilledButton( style: context._onboardingGlassButtonStyle, onPressed: onBuilderlab, - child: Text(builderlabLabel), + child: builderlabBusy + ? BuzzLoadingIndicator( + size: 20, + color: context._onboardingInk, + semanticLabel: builderlabLabel, + ) + : Text(builderlabLabel), ), + if (!showConnectionOptions) ...[ const SizedBox(height: Grid.xxs), + TextButton( + style: context._onboardingGhostButtonStyle, + onPressed: isBusy + ? null + : () => optionsExpanded.value = !optionsExpanded.value, + child: const Text('Other options'), + ), ], - FilledButton( - style: context._onboardingGlassButtonStyle, - onPressed: onCommunityLink, - child: const Text('Enter a community link'), - ), - if (HostedEnvironment.enabled && onCreateCommunity != null) ...[ - const SizedBox(height: Grid.xxs), - FilledButton( - style: context._onboardingGlassButtonStyle, + if (showConnectionOptions) ...connectionActions(), + if (HostedEnvironment.enabled && + showConnectionOptions && + onCreateCommunity != null) + TextButton( onPressed: isBusy ? null : onCreateCommunity, child: const Text('Create a community'), ), - ], - const SizedBox(height: Grid.xxs), - FilledButton( - style: context._onboardingGlassButtonStyle, - onPressed: isBusy ? null : onScan, - child: const Text('Scan a QR code'), - ), - const SizedBox(height: Grid.xxs), - TextButton( - style: context._onboardingGhostButtonStyle, - onPressed: isBusy ? null : onAlreadyUseBuzz, - child: const Text('Use pairing code'), - ), ] else ...[ if (!connectionLanding) ...[ FilledButton( @@ -155,8 +184,8 @@ class _PairingWelcomeView extends StatelessWidget { ], AnimatedSwitcher( duration: revealDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, + switchInCurve: Curves.easeOutQuad, + switchOutCurve: Curves.easeOutCubic, transitionBuilder: (child, animation) { return SizeTransition( sizeFactor: animation, @@ -269,7 +298,7 @@ class _PairingWelcomeView extends StatelessWidget { ], ); - return Padding( + final welcome = Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.gutter, vertical: Grid.sm, @@ -343,7 +372,9 @@ class _PairingWelcomeView extends StatelessWidget { 0, ), duration: revealDuration, - curve: Curves.easeInOutCubic, + curve: page == selectedPage + ? Curves.easeOutQuad + : Curves.easeOutCubic.flipped, child: SizedBox( width: double.infinity, child: actions(page), @@ -363,5 +394,79 @@ class _PairingWelcomeView extends StatelessWidget { ], ), ); + return PopScope( + canPop: !showingOptions, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && showingOptions && !enteringCommunityLink) { + optionsExpanded.value = false; + } + }, + child: TweenAnimationBuilder( + tween: Tween(end: showingOptions ? 1 : 0), + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: showingOptions + ? Curves.easeOutQuad + : Curves.easeOutCubic.flipped, + child: welcome, + builder: (context, progress, child) => Stack( + fit: StackFit.expand, + children: [ + ExcludeFocus( + excluding: showingOptions, + child: ExcludeSemantics( + excluding: showingOptions, + child: IgnorePointer( + ignoring: showingOptions, + child: Opacity( + opacity: 1 - progress, + child: ImageFiltered( + enabled: !reducedMotion && progress > 0 && progress < 1, + imageFilter: ImageFilter.blur( + sigmaX: 16 * progress, + sigmaY: 16 * progress, + ), + child: child, + ), + ), + ), + ), + ), + if (showingOptions || progress > 0) ...[ + Opacity( + opacity: progress, + child: Transform.translate( + offset: Offset(0, reducedMotion ? 0 : 16 * (1 - progress)), + child: IgnorePointer( + ignoring: !showingOptions, + child: ExcludeFocus( + excluding: !showingOptions, + child: ExcludeSemantics( + excluding: !showingOptions, + child: _OnboardingConnectionView( + enteringLink: enteringCommunityLink, + onCommunityLink: onCommunityLink, + onBack: () { + if (enteringCommunityLink) { + onWelcomeBack?.call(); + } else { + optionsExpanded.value = false; + } + }, + onOpenCommunity: onOpenCommunity, + onScan: isBusy ? null : onScan, + onPairing: isBusy ? null : onAlreadyUseBuzz, + ), + ), + ), + ), + ), + ), + ], + ], + ), + ), + ); } } 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 91ae8655b9..454edd4ba1 100644 --- a/mobile/lib/features/pairing/pairing_page/sas_verification_view.dart +++ b/mobile/lib/features/pairing/pairing_page/sas_verification_view.dart @@ -236,7 +236,7 @@ class _SasVerificationView extends HookConsumerWidget { TextButton( style: context._onboardingSecondaryButtonStyle.copyWith( minimumSize: const WidgetStatePropertyAll( - Size.fromHeight(48), + Size.fromHeight(52), ), ), onPressed: showProtection diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 33e3582787..8c39704d2e 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -46,8 +46,12 @@ class AnimatedAvatarCapture extends HookConsumerWidget { required this.onPrepareChanged, this.initialFrames = const [], this.disposalBarrier, + this.previewKey, }); + /// Identifies the visible avatar for the native sheet transition. + final GlobalKey? previewKey; + /// The vertical space available to the capture surface. final double height; @@ -427,6 +431,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { .toDouble(); }, child: SizedBox.square( + key: previewKey, dimension: _animatedAvatarPreviewSize, child: Stack( fit: StackFit.expand, @@ -585,6 +590,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { key: const ValueKey('animated-avatar-capture-preview'), dimension: capturePreviewSize, child: Stack( + key: previewKey, fit: StackFit.expand, children: [ Padding( diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart index 8fc48efaf3..2b40b2cf6b 100644 --- a/mobile/lib/features/profile/profile_avatar_draft.dart +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -130,3 +130,30 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { } } } + +/// Transfers only local draft data between the app and its native sheet engine. +Map? encodeProfileAvatarDraft(ProfileAvatarDraft? draft) => + switch (draft) { + ProfileImageAvatarDraft(:final bytes) => { + 'type': 'image', + 'bytes': bytes, + }, + ProfileAnimatedAvatarDraft(:final animation, :final poster) => { + 'type': 'animated', + 'animation': animation, + 'poster': poster, + }, + ProfileUrlAvatarDraft(:final url) => {'type': 'url', 'url': url}, + null => null, + }; + +ProfileAvatarDraft? decodeProfileAvatarDraft(Map? value) => + switch (value?['type']) { + 'image' => ProfileImageAvatarDraft(value!['bytes'] as Uint8List), + 'animated' => ProfileAnimatedAvatarDraft( + animation: value!['animation'] as Uint8List, + poster: value['poster'] as Uint8List, + ), + 'url' => ProfileUrlAvatarDraft(value!['url'] as String), + _ => null, + }; diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 65cd9f77bd..cc003d7123 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -85,11 +85,20 @@ class ProfileAvatarEditor extends HookConsumerWidget { required this.onImageCameraActiveChanged, this.animatedCaptureBuilder, this.imageCaptureBuilder, + this.previewKey, + this.previewOpacity = 1, + this.emptyPreview, + this.headerGap = Grid.xs, }); /// The avatar URL shown until the user selects a new draft. final String? currentAvatarUrl; + final GlobalKey? previewKey; + final double previewOpacity; + final Widget? emptyPreview; + final double headerGap; + /// The text initial used when [currentAvatarUrl] has no displayable image. final String fallbackInitial; @@ -252,6 +261,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { (draft as ProfileImageAvatarDraft).bytes, ), ), + ProfileAvatarMode.image when previewUrl == null && emptyPreview != null => + SizedBox.square(dimension: _previewSize, child: emptyPreview), ProfileAvatarMode.image => PlayingAvatarImage( key: const ValueKey('avatar-editor-fixed-preview'), imageUrl: previewUrl, @@ -318,7 +329,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { reverseCurve: _exitCurve, ); final appBarHeight = frostedAppBarHeight(context); - final topPadding = appBarHeight + Grid.xs; + final topPadding = appBarHeight + headerGap; return LayoutBuilder( builder: (context, constraints) { @@ -421,6 +432,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { onPrepareChanged: onAnimatedPrepareChanged, ) ?? AnimatedAvatarCapture( + previewKey: previewKey, height: modeHeight, onPrepareChanged: onAnimatedPrepareChanged, disposalBarrier: cameraDisposal.value, @@ -506,7 +518,10 @@ class ProfileAvatarEditor extends HookConsumerWidget { child: Center( child: AnimatedBuilder( animation: curvedEntrance, - child: fixedPreview, + child: Opacity( + opacity: previewOpacity, + child: KeyedSubtree(key: previewKey, child: fixedPreview), + ), builder: (context, child) { final progress = curvedEntrance.value; return Transform.translate( diff --git a/mobile/lib/features/profile/profile_avatar_sheet_app.dart b/mobile/lib/features/profile/profile_avatar_sheet_app.dart new file mode 100644 index 0000000000..0ad67af261 --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_sheet_app.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../shared/theme/app_theme.dart'; +import '../../shared/theme/theme_provider.dart'; +import 'profile_avatar_draft.dart'; +import 'profile_setup_avatar_editor.dart'; + +/// A local-only editor hosted inside the existing UIKit sheet's navigation stack. +@pragma('vm:entry-point') +Future profileAvatarEditorMain() async { + WidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('buzz/profile_avatar_sheet'); + final initial = + await channel.invokeMapMethod('initial') ?? {}; + final prefs = await SharedPreferences.getInstance(); + runApp( + ProviderScope( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + child: MaterialApp( + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: initial['dark'] == true ? ThemeMode.dark : ThemeMode.light, + home: ProfileSetupAvatarEditor( + initialDraft: decodeProfileAvatarDraft(initial['draft'] as Map?), + onPreviewReady: (rect) => channel.invokeMethod('ready', { + 'x': rect.left, + 'y': rect.top, + 'width': rect.width, + 'height': rect.height, + }), + onFinished: (draft) => channel.invokeMethod( + 'finish', + encodeProfileAvatarDraft(draft), + ), + ), + ), + ), + ); +} diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index cedda77005..d3df16d85e 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -133,6 +133,12 @@ class ProfileNotifier extends AsyncNotifier { /// Updates the current user's display name while preserving the other /// metadata fields in their latest kind:0 profile event. + Future updateSetup(String displayName, String? avatarUrl) => + _publishProfilePatch({ + 'display_name': displayName.trim(), + 'picture': ?avatarUrl, + }); + Future updateDisplayName(String displayName) => _publishProfilePatch({'display_name': displayName.trim()}); diff --git a/mobile/lib/features/profile/profile_setup_avatar_editor.dart b/mobile/lib/features/profile/profile_setup_avatar_editor.dart new file mode 100644 index 0000000000..91d84ffa85 --- /dev/null +++ b/mobile/lib/features/profile/profile_setup_avatar_editor.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; + +import '../../shared/widgets/ios_glass_navigation_button.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/ios_glass_navigation_action.dart'; +import 'profile_setup_empty_avatar.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'profile_avatar_draft.dart'; +import 'profile_avatar_editor.dart'; + +/// Reuses the profile editor while returning a local draft instead of publishing. +class ProfileSetupAvatarEditor extends HookConsumerWidget { + const ProfileSetupAvatarEditor({ + super.key, + this.initialDraft, + this.onFinished, + this.onPreviewReady, + }); + final ProfileAvatarDraft? initialDraft; + final ValueChanged? onFinished; + final Future Function(Rect)? onPreviewReady; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final previewVisible = useState(onPreviewReady == null); + final previewKey = useMemoized(() => GlobalKey()); + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + final box = previewKey.currentContext?.findRenderObject(); + if (box is RenderBox && box.hasSize) { + try { + await onPreviewReady + ?.call(box.localToGlobal(Offset.zero) & box.size) + .timeout(const Duration(seconds: 2), onTimeout: () {}); + } finally { + if (context.mounted) previewVisible.value = true; + } + } + }); + return null; + }, const []); + final draft = useState(initialDraft); + final mode = useState(switch (initialDraft) { + ProfileUrlAvatarDraft() => ProfileAvatarMode.emoji, + ProfileAnimatedAvatarDraft() => ProfileAvatarMode.animated, + _ => ProfileAvatarMode.image, + }); + final prepare = useState Function()?>(null); + final busy = useState(false); + final error = useState(false); + final transition = useAnimationController(initialValue: 1); + final VoidCallback? close = busy.value + ? null + : () { + if (onFinished != null) { + onFinished!(null); + } else { + Navigator.of(context).pop(); + } + }; + final VoidCallback? done = + busy.value || (draft.value == null && prepare.value == null) + ? null + : () async { + busy.value = true; + error.value = false; + try { + final result = + mode.value == ProfileAvatarMode.animated && + prepare.value != null + ? await prepare.value!() + : draft.value; + if (context.mounted && result != null) { + if (onFinished != null) { + onFinished!(result); + } else { + Navigator.of(context).pop(result); + } + } + } catch (_) { + if (context.mounted) error.value = true; + } finally { + if (context.mounted) busy.value = false; + } + }; + return PopScope( + canPop: !busy.value, + child: FrostedScaffold( + useUtilitySurfaceTheme: true, + resizeToAvoidBottomInset: false, + appBar: FrostedAppBar( + nativeTitle: 'Profile photo', + centerTitle: true, + title: const Text('Profile photo'), + leading: defaultTargetPlatform == TargetPlatform.iOS + ? IosGlassNavigationButton( + icon: IosGlassNavigationIcon.back, + semanticLabel: 'Back to profile', + onPressed: close, + ) + : IconButton( + onPressed: close, + tooltip: 'Back to profile', + icon: const Icon(Icons.arrow_back), + ), + actions: [ + if (defaultTargetPlatform == TargetPlatform.iOS) + IosGlassNavigationAction( + label: 'Done', + width: 72, + isBusy: busy.value, + onPressed: done, + ) + else + TextButton( + onPressed: done, + child: Text(busy.value ? 'Preparing…' : 'Done'), + ), + ], + ), + body: Column( + children: [ + if (error.value) + const Text('Could not prepare this photo. Try again.'), + Expanded( + child: IgnorePointer( + ignoring: busy.value, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: constraints.maxHeight < 720 + ? const ClampingScrollPhysics() + : const NeverScrollableScrollPhysics(), + child: SizedBox( + height: constraints.maxHeight < 720 + ? 720 + : constraints.maxHeight, + child: ProfileAvatarEditor( + previewKey: previewKey, + headerGap: Grid.lg, + previewOpacity: previewVisible.value ? 1 : 0, + currentAvatarUrl: initialDraft is ProfileUrlAvatarDraft + ? (initialDraft as ProfileUrlAvatarDraft).url + : null, + fallbackInitial: '+', + emptyPreview: const ProfileSetupEmptyAvatar(), + draft: draft.value, + mode: mode.value, + transition: transition, + onModeChanged: (value) { + mode.value = value; + draft.value = null; + prepare.value = null; + }, + onDraftChanged: (value) => draft.value = value, + onAnimatedPrepareChanged: (value) { + prepare.value = value; + }, + onImageCameraActiveChanged: (_) {}, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/profile/profile_setup_empty_avatar.dart b/mobile/lib/features/profile/profile_setup_empty_avatar.dart new file mode 100644 index 0000000000..2af32a2ca0 --- /dev/null +++ b/mobile/lib/features/profile/profile_setup_empty_avatar.dart @@ -0,0 +1,62 @@ +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; + +import '../../shared/theme/buzz_icons.dart'; + +/// Matches the native setup avatar, scaled from its 96-point source bounds. +class ProfileSetupEmptyAvatar extends StatelessWidget { + const ProfileSetupEmptyAvatar({super.key}); + + @override + Widget build(BuildContext context) => FittedBox( + child: SizedBox.square( + dimension: 96, + child: CustomPaint( + painter: _RingPainter( + CupertinoDynamicColor.resolve(CupertinoColors.tertiaryLabel, context), + ), + child: Center( + child: Icon( + BuzzIcons.plus, + size: 28, + color: CupertinoDynamicColor.resolve( + CupertinoColors.label, + context, + ), + ), + ), + ), + ), + ); +} + +class _RingPainter extends CustomPainter { + const _RingPainter(this.color); + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + final bounds = Offset.zero & size; + final radius = (size.width - 4) / 2; + final circumference = 2 * math.pi * radius; + final count = (circumference / 9).round(); + final step = 2 * math.pi / count; + for (var index = 0; index < count; index++) { + canvas.drawArc( + bounds.deflate(2), + index * step, + step * 4 / 9, + false, + paint, + ); + } + } + + @override + bool shouldRepaint(_RingPainter oldDelegate) => oldDelegate.color != color; +} diff --git a/mobile/lib/features/profile/profile_setup_page.dart b/mobile/lib/features/profile/profile_setup_page.dart new file mode 100644 index 0000000000..59f295ac3b --- /dev/null +++ b/mobile/lib/features/profile/profile_setup_page.dart @@ -0,0 +1,311 @@ +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'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/hosted/hosted_flow.dart'; +import '../../shared/emoji/emoji_avatar.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +// Retain the named entry point used by the native sheet's editor engine. +// ignore: unused_import +import 'profile_avatar_sheet_app.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import 'profile_avatar_draft.dart'; +import '../../shared/widgets/playing_avatar_image.dart'; +import 'profile_setup_avatar_editor.dart'; + +/// One optional profile sheet for onboarding and existing-community entry. +class ProfileSetupPage extends HookConsumerWidget { + const ProfileSetupPage({super.key, required this.flow}); + final HostedFlow flow; + static const _channel = MethodChannel('buzz/profile_setup'); + static int _nextSession = 0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = utilitySurfaceThemeData(Theme.of(context)); + useEffect(() { + final session = ++_nextSession; + var active = true; + var name = ''; + final avatar = ValueNotifier(null); + ModalRoute? profileRoute; + ModalRoute? editorRoute; + bool current() => + active && context.mounted && flow.stage == HostedFlowStage.profile; + Future save() async { + if (!current()) return; + final draft = avatar.value; + final service = draft == null + ? null + : ref.read(mediaUploadServiceProvider); + await flow.saveProfileDraft!( + name.trim(), + draft == null ? null : () => draft.upload(service!), + ); + } + + Future editAvatar() async { + if (!current()) return; + final result = await showBuzzModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + useSafeArea: true, + showCloseButton: false, + padBottom: false, + isDismissible: false, + enableDrag: false, + builder: (sheetContext) { + editorRoute = ModalRoute.of(sheetContext); + return SizedBox( + height: MediaQuery.sizeOf(sheetContext).height * .94, + child: ProfileSetupAvatarEditor(initialDraft: avatar.value), + ); + }, + ); + if (!current()) return; + editorRoute = null; + if (result != null) avatar.value = result; + } + + Future fallback() async { + if (!current()) return; + await showBuzzModalBottomSheet( + context: context, + title: 'Set up your profile', + isDismissible: false, + enableDrag: false, + showCloseButton: false, + useRootNavigator: true, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) { + profileRoute = ModalRoute.of(sheetContext); + return _ProfileSetupForm( + avatar: avatar, + onName: (value) => name = value, + editAvatar: editAvatar, + save: save, + skip: flow.finishProfile, + ); + }, + ); + if (!current()) return; + profileRoute = null; + if (current()) flow.finishProfile(); + } + + Future present() async { + if (defaultTargetPlatform != TargetPlatform.iOS) { + await fallback(); + return; + } + _channel.setMethodCallHandler((call) async { + if (!current() || (call.arguments as Map?)?['session'] != session) { + return null; + } + switch (call.method) { + case 'avatarDraft': + avatar.value = decodeProfileAvatarDraft( + (call.arguments as Map)['draft'] as Map?, + ); + final emoji = avatar.value is ProfileUrlAvatarDraft + ? parseEmojiAvatarDataUrl( + (avatar.value as ProfileUrlAvatarDraft).url, + ) + : null; + return { + 'avatar': switch (avatar.value) { + ProfileImageAvatarDraft(:final bytes) => bytes, + ProfileAnimatedAvatarDraft(:final poster) => poster, + _ => null, + }, + 'emoji': emoji?.emoji, + 'emojiColor': emoji?.colorValue, + }; + case 'save': + name = ((call.arguments as Map)['name'] as String? ?? '').trim(); + if (name.isEmpty || name.characters.length > 100) { + return {'saved': false}; + } + try { + await save(); + return {'saved': true}; + } catch (_) { + return {'saved': false}; + } + } + return null; + }); + + try { + while (current()) { + final Uint8List? bytes = switch (avatar.value) { + ProfileImageAvatarDraft(:final bytes) => bytes, + ProfileAnimatedAvatarDraft(:final poster) => poster, + _ => null, + }; + final emoji = avatar.value is ProfileUrlAvatarDraft + ? parseEmojiAvatarDataUrl( + (avatar.value as ProfileUrlAvatarDraft).url, + ) + : null; + final action = await _channel.invokeMethod('present', { + 'session': session, + 'name': name, + 'avatar': bytes, + 'emoji': emoji?.emoji, + 'emojiColor': emoji?.colorValue, + 'dark': theme.brightness == Brightness.dark, + 'pageBackgroundArgb': theme.colorScheme.surface.toARGB32(), + 'containerBackgroundArgb': theme + .colorScheme + .surfaceContainerHighest + .toARGB32(), + 'containerCornerRadius': Radii.container, + + 'primaryArgb': theme.colorScheme.primary.toARGB32(), + }); + if (!current()) return; + if (action != 'saved') flow.finishProfile(); + return; + } + } on MissingPluginException { + await fallback(); + } on PlatformException { + await fallback(); + } finally { + if (active) _channel.setMethodCallHandler(null); + } + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (current()) unawaited(present()); + }); + return () { + active = false; + if (defaultTargetPlatform == TargetPlatform.iOS) { + _channel.setMethodCallHandler(null); + unawaited( + _channel + .invokeMethod('dismiss', {'session': session}) + .catchError((Object _) {}), + ); + } + final routes = [editorRoute, profileRoute]; + avatar.dispose(); + WidgetsBinding.instance.addPostFrameCallback((_) { + for (final route in routes) { + if (route != null && route.isActive) { + route.navigator?.removeRoute(route); + } + } + }); + }; + }, [flow]); + return const SizedBox.shrink(); + } +} + +class _ProfileSetupForm extends HookWidget { + const _ProfileSetupForm({ + required this.avatar, + required this.onName, + required this.editAvatar, + required this.save, + required this.skip, + }); + final ValueNotifier avatar; + final ValueChanged onName; + final Future Function() editAvatar; + final Future Function() save; + final VoidCallback skip; + @override + Widget build(BuildContext context) { + final draft = useValueListenable(avatar); + final name = useTextEditingController(); + useListenable(name); + final busy = useState(false); + final error = useState(false); + return PopScope( + canPop: !busy.value, + child: Padding( + padding: EdgeInsets.fromLTRB( + 20, + 16, + 20, + MediaQuery.viewInsetsOf(context).bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + IconButton( + onPressed: busy.value ? null : editAvatar, + icon: switch (draft) { + ProfileImageAvatarDraft(:final bytes) => CircleAvatar( + radius: 32, + backgroundImage: MemoryImage(bytes), + ), + ProfileAnimatedAvatarDraft(:final poster) => CircleAvatar( + radius: 32, + backgroundImage: MemoryImage(poster), + ), + ProfileUrlAvatarDraft(:final url) => PlayingAvatarImage( + imageUrl: url, + radius: 32, + fallback: const Icon(Icons.add_a_photo_outlined), + ), + null => const Icon(Icons.add_a_photo_outlined), + }, + iconSize: 64, + tooltip: 'Add profile photo', + ), + TextField( + controller: name, + maxLength: 100, + enabled: !busy.value, + onChanged: onName, + decoration: const InputDecoration(labelText: 'Display name'), + ), + if (error.value) + const Text('Could not save your profile. Try again or skip.'), + FilledButton( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ), + onPressed: busy.value || name.text.trim().isEmpty + ? null + : () async { + busy.value = true; + error.value = false; + try { + await save(); + } catch (_) { + if (context.mounted) error.value = true; + } finally { + if (context.mounted) busy.value = false; + } + }, + child: Text(busy.value ? 'Saving…' : 'Continue'), + ), + OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: const StadiumBorder(), + ), + onPressed: busy.value ? null : skip, + child: const Text('Skip'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/hosted_app_gate.dart b/mobile/lib/hosted_app_gate.dart index 27833b2955..490943e18a 100644 --- a/mobile/lib/hosted_app_gate.dart +++ b/mobile/lib/hosted_app_gate.dart @@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'features/channels/deep_link_dispatcher.dart'; +import 'features/profile/profile_setup_page.dart'; import 'features/onboarding/hosted_connect_page.dart'; import 'features/onboarding/self_hosted_entry_sheet.dart'; import 'shared/auth/auth.dart'; @@ -166,12 +167,13 @@ class HostedAppGate extends HookConsumerWidget { ), ), ), + if (flow.stage == HostedFlowStage.profile) + ProfileSetupPage(flow: flow), if (visible && + flow.stage != HostedFlowStage.profile && !openingSignIn && !inlineAccountStatus && (!inlineEntry || - (flow.accountOnly && - flow.stage == HostedFlowStage.profile) || flow.stage == HostedFlowStage.chooseIdentity || flow.stage == HostedFlowStage.chooseCommunities || flow.stage == HostedFlowStage.communityConflict || diff --git a/mobile/lib/onboarding_app_entry.dart b/mobile/lib/onboarding_app_entry.dart index dbf3e3541d..ba4d01219d 100644 --- a/mobile/lib/onboarding_app_entry.dart +++ b/mobile/lib/onboarding_app_entry.dart @@ -78,6 +78,7 @@ class OnboardingAppEntry extends HookConsumerWidget { flow.account.account == null) && flow.stage != HostedFlowStage.idle && flow.stage != HostedFlowStage.complete && + flow.stage != HostedFlowStage.profile && flow.stage != HostedFlowStage.chooseIdentity && flow.stage != HostedFlowStage.chooseCommunities && flow.stage != HostedFlowStage.communityConflict && @@ -95,7 +96,7 @@ class OnboardingAppEntry extends HookConsumerWidget { : problem != null ? hostedFlowProblemMessage(problem) : busy - ? 'Connecting to Builderlab…' + ? null : flow.noCommunitiesToAdd ? 'No communities available to add. Scan a QR code or use an invite link.' : connected @@ -117,10 +118,18 @@ class OnboardingAppEntry extends HookConsumerWidget { !creating.value && !inlineSetup, onPopInvokedWithResult: (didPop, _) { - if (!didPop) back(); + if (!didPop && + (existingUser.value || + creating.value || + enteringCommunityLink.value || + inlineSetup)) { + back(); + } }, child: PairingPage( connectionLanding: true, + showConnectionOptions: + !HostedEnvironment.enabled || addingCommunity || connected, addingCommunity: addingCommunity, existingUser: existingUser.value, enteringCommunityLink: enteringCommunityLink.value, @@ -143,6 +152,7 @@ class OnboardingAppEntry extends HookConsumerWidget { ? null : () => unawaited(flow.signInAccount()), accountStatus: status, + builderlabBusy: busy, setupContent: flow != null && inlineSetup ? Column( mainAxisSize: MainAxisSize.min, @@ -158,7 +168,7 @@ class OnboardingAppEntry extends HookConsumerWidget { ? 'Connecting…' : connected ? 'Continue with Builderlab' - : 'Connect to Builderlab', + : 'Sign in with builderlab', onWelcomeBack: back, onAlreadyUseBuzz: () => existingUser.value = true, ), diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index e57affb470..a75f721cd0 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -124,23 +124,35 @@ class HostedFlow extends ChangeNotifier { Completer? _profileCompletion; Future Function(String)? saveProfile; - Future requestProfile(Future Function(String) save) async { + Future Function(String, Future Function()?)? saveProfileDraft; + + Future requestProfile( + Future Function(String) save, { + Future Function(String, Future Function()?)? saveDraft, + }) async { if (_disposed) return; final completion = Completer(); _profileCompletion = completion; - saveProfile = (name) async { + saveProfileDraft = (name, avatar) async { if (!identical(_profileCompletion, completion) || completion.isCompleted) { throw AccountFailure.unavailable; } - await save(name); + if (saveDraft != null) { + await saveDraft(name, avatar); + } else { + await save(name); + } if (identical(_profileCompletion, completion)) finishProfile(); }; + final saveCurrentDraft = saveProfileDraft!; + saveProfile = (name) => saveCurrentDraft(name, null); _set(HostedFlowStage.profile); await completion.future; if (identical(_profileCompletion, completion)) { _profileCompletion = null; saveProfile = null; + saveProfileDraft = null; _set(HostedFlowStage.working); } } @@ -154,6 +166,7 @@ class HostedFlow extends ChangeNotifier { final completion = _profileCompletion; _profileCompletion = null; saveProfile = null; + saveProfileDraft = null; if (completion != null && !completion.isCompleted) completion.complete(); } diff --git a/mobile/lib/shared/hosted/hosted_flow_provider.dart b/mobile/lib/shared/hosted/hosted_flow_provider.dart index d39411ca2e..ff9d4c1001 100644 --- a/mobile/lib/shared/hosted/hosted_flow_provider.dart +++ b/mobile/lib/shared/hosted/hosted_flow_provider.dart @@ -153,10 +153,23 @@ Future offerHostedProfile( } catch (_) { return; } - await flow.requestProfile((name) async { - if (!current()) throw AccountFailure.unavailable; - await ref.read(profileProvider.notifier).updateDisplayName(name); - }); + await flow.requestProfile( + (name) async { + if (!current()) throw AccountFailure.unavailable; + await ref.read(profileProvider.notifier).updateDisplayName(name); + }, + saveDraft: (name, prepareAvatar) async { + if (!current()) throw AccountFailure.unavailable; + final writer = ref.read(profileProvider.notifier); + final avatar = await prepareAvatar?.call(); + if (!current()) throw AccountFailure.unavailable; + if (avatar == null) { + await writer.updateDisplayName(name); + } else { + await writer.updateSetup(name, avatar); + } + }, + ); } finally { subscription.close(); } diff --git a/mobile/lib/shared/identity_transfer/native_community_entry.dart b/mobile/lib/shared/identity_transfer/native_community_entry.dart new file mode 100644 index 0000000000..22fa26daf0 --- /dev/null +++ b/mobile/lib/shared/identity_transfer/native_community_entry.dart @@ -0,0 +1,129 @@ +import 'package:flutter/services.dart'; + +import '../hosted/account_identity_store.dart'; +import '../hosted/device_identity_source.dart'; +import '../utils/string_utils.dart'; +import 'nsec_transfer.dart'; + +/// UIKit presentation with identity operations kept in the Dart service layer. +abstract final class NativeCommunityEntry { + static const _channel = MethodChannel('buzz/community_entry'); + static bool _presenting = false; + static int _session = 0; + + /// Returns null when native presentation is unavailable. + static Future show({ + required String name, + required bool dark, + required int primaryArgb, + required List choices, + Future> Function()? confirmInvite, + required Future Function( + String? pubkey, + String? nsec, + bool create, + bool Function() isCurrent, + ) + connect, + }) async { + if (_presenting) return false; + _presenting = true; + final session = ++_session; + var active = true; + var generation = 0; + var busy = false; + String? previewed; + _channel.setMethodCallHandler((call) async { + final args = Map.from(call.arguments as Map); + if (!active || args['session'] != session) return {'cancelled': true}; + if (call.method == 'cancel') { + generation++; + busy = false; + previewed = null; + return null; + } + if (busy) return {'cancelled': true}; + final attempt = ++generation; + bool current() => active && generation == attempt; + busy = true; + try { + switch (call.method) { + case 'preview': + case 'file': + final text = call.method == 'file' + ? await NsecTransfer.pickFile() + : args['nsec'] as String?; + if (!current() || text == null) return {'cancelled': true}; + previewed = NsecTransfer.preview(text); + return { + 'npub': fullNpub(previewed!), + if (call.method == 'file') 'nsec': text, + }; + case 'join': + if (confirmInvite != null) return await confirmInvite(); + final nsec = args['nsec'] as String?; + final pubkey = args['pubkey'] as String?; + final create = args['create'] == true; + if (nsec != null) { + if (previewed == null || + NsecTransfer.preview(nsec) != previewed) { + throw AccountIdentityFailure.invalidKey; + } + } else if (create + ? choices.isNotEmpty + : !choices.any((choice) => choice.pubkey == pubkey)) { + return {'error': 'Choose an identity to continue.'}; + } + await connect(pubkey, nsec, create, current); + return current() ? {'joined': true} : {'cancelled': true}; + default: + throw MissingPluginException(); + } + } catch (error) { + if (!current()) return {'cancelled': true}; + return { + 'error': switch (error) { + AccountIdentityFailure.invalidKey => + 'Enter a valid nsec private key.', + AccountIdentityFailure.occupied || + AccountIdentityFailure.mismatch => + 'This device already has a different identity. It has not been replaced.', + _ => 'Could not finish. Try again. Your saved keys have been kept.', + }, + }; + } finally { + if (current()) busy = false; + } + }); + try { + return await _channel.invokeMethod('present', { + 'session': session, + 'name': name, + 'dark': dark, + 'primaryArgb': primaryArgb, + 'invite': confirmInvite != null, + 'choices': [ + for (final choice in choices) + { + 'pubkey': choice.pubkey, + 'label': choice.label, + 'npub': fullNpub(choice.pubkey) ?? choice.pubkey, + }, + ], + }) ?? + false; + } on MissingPluginException { + return null; + } on PlatformException catch (error) { + // Scene transitions can temporarily leave UIKit without a presenter. + if (error.code == 'presentation_failed') return null; + rethrow; + } finally { + active = false; + generation++; + previewed = null; + _channel.setMethodCallHandler(null); + _presenting = false; + } + } +} diff --git a/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart b/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart index b5302b448f..a3a411ceaf 100644 --- a/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart +++ b/mobile/lib/shared/identity_transfer/nsec_import_sheet.dart @@ -1,3 +1,5 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -23,14 +25,32 @@ Future showNsecImportSheet( /// Transient secret input; only public preview and safe errors are displayed. class NsecImportSheet extends HookWidget { - const NsecImportSheet({super.key, required this.onImport, this.pickFile}); + const NsecImportSheet({ + super.key, + required this.onImport, + this.pickFile, + this.autofocus = true, + }); final Future Function(String nsec) onImport; final Future Function()? pickFile; + /// Allows a containing sheet to finish expanding before opening the keyboard. + final bool autofocus; + @override Widget build(BuildContext context) { final input = useTextEditingController(); + final focus = useFocusNode(); + final autofocusRef = useRef(autofocus)..value = autofocus; + useEffect(() { + if (autofocus && defaultTargetPlatform == TargetPlatform.iOS) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted && autofocusRef.value) focus.requestFocus(); + }); + } + return null; + }, [autofocus]); final publicKey = useState(null); final error = useState(null); final busy = useState(false); @@ -93,27 +113,51 @@ class NsecImportSheet extends HookWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Text( - 'Your nsec is your private key. Only import a key you trust. It is never copied to the clipboard.', - ), - const SizedBox(height: Grid.sm), - TextField( - controller: input, - maxLength: 1024, - enabled: !busy.value, - obscureText: true, - autocorrect: false, - enableSuggestions: false, - enableIMEPersonalizedLearning: false, - decoration: const InputDecoration( - labelText: 'Private key (nsec)', + 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(), ), - onChanged: (_) { - publicKey.value = null; - error.value = null; - }, - ), - TextButton( onPressed: busy.value ? null : chooseFile, child: const Text('Choose nsec file'), ), @@ -127,7 +171,12 @@ class NsecImportSheet extends HookWidget { ], 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 diff --git a/mobile/lib/shared/theme/buzz_icons.dart b/mobile/lib/shared/theme/buzz_icons.dart index 9ced63f285..c631b39527 100644 --- a/mobile/lib/shared/theme/buzz_icons.dart +++ b/mobile/lib/shared/theme/buzz_icons.dart @@ -13,6 +13,9 @@ abstract final class BuzzIcons { static const edit = IconData(0xea98, fontFamily: 'BuzzTabler'); + /// Tabler `qrcode`. + static const qrcode = IconData(0xeb11, fontFamily: 'BuzzTabler'); + /// Tabler `text-size`. static const aLargeSmall = IconData(0xf2b1, fontFamily: 'BuzzTabler'); diff --git a/mobile/lib/shared/widgets/buzz_sheet_header.dart b/mobile/lib/shared/widgets/buzz_sheet_header.dart index 14353bcd33..97bdedc459 100644 --- a/mobile/lib/shared/widgets/buzz_sheet_header.dart +++ b/mobile/lib/shared/widgets/buzz_sheet_header.dart @@ -16,6 +16,7 @@ class BuzzSheetHeader extends StatelessWidget { this.titleKey, this.leading, this.trailing, + this.onClose, this.showDragHandle = false, }); @@ -23,6 +24,9 @@ class BuzzSheetHeader extends StatelessWidget { final Key? titleKey; final Widget? leading; final Widget? trailing; + + /// Overrides route dismissal while retaining the standard close control. + final VoidCallback? onClose; final bool showDragHandle; @override @@ -61,7 +65,7 @@ class BuzzSheetHeader extends StatelessWidget { ), Align( alignment: Alignment.bottomRight, - child: trailing ?? const _SheetCloseButton(), + child: trailing ?? _SheetCloseButton(onClose: onClose), ), if (leading case final leading?) Align(alignment: Alignment.bottomLeft, child: leading), @@ -73,13 +77,18 @@ class BuzzSheetHeader extends StatelessWidget { } class _SheetCloseButton extends StatelessWidget { - const _SheetCloseButton(); + const _SheetCloseButton({this.onClose}); + final VoidCallback? onClose; @override Widget build(BuildContext context) { void closeSheet() { unawaited(HapticFeedback.lightImpact()); - Navigator.of(context).pop(); + if (onClose != null) { + onClose!(); + } else { + Navigator.of(context).pop(); + } } if (Theme.of(context).platform == TargetPlatform.iOS) { diff --git a/mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart b/mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart index 96874f9d6f..74a16a01f4 100644 --- a/mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart +++ b/mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart @@ -13,6 +13,7 @@ class BuzzTitledSheetLayout extends HookWidget { required this.child, this.leading, this.trailing, + this.onClose, this.titleKey, this.showDragHandle = false, this.surfaceColor, @@ -22,6 +23,9 @@ class BuzzTitledSheetLayout extends HookWidget { final Widget child; final Widget? leading; final Widget? trailing; + + /// Overrides the header close action; null dismisses the current route. + final VoidCallback? onClose; final Key? titleKey; final bool showDragHandle; final Color? surfaceColor; @@ -79,6 +83,7 @@ class BuzzTitledSheetLayout extends HookWidget { titleKey: titleKey, leading: leading, trailing: trailing, + onClose: onClose, showDragHandle: showDragHandle, ), ), diff --git a/mobile/lib/shared/widgets/modal_presentation.dart b/mobile/lib/shared/widgets/modal_presentation.dart index 50e48df5d2..54caebf961 100644 --- a/mobile/lib/shared/widgets/modal_presentation.dart +++ b/mobile/lib/shared/widgets/modal_presentation.dart @@ -23,6 +23,7 @@ const buzzModalAnimationStyle = AnimationStyle( /// uses native concentric corners when available and paints a requested drag /// handle inside the shared header so its spacing is consistent on every /// platform. +/// Set [padBottom] to false when the content owns its bottom edge spacing. Future showBuzzModalBottomSheet({ required BuildContext context, required WidgetBuilder builder, @@ -42,6 +43,7 @@ Future showBuzzModalBottomSheet({ bool? showDragHandle, bool showCloseButton = true, bool useSafeArea = false, + bool padBottom = true, RouteSettings? routeSettings, AnimationController? transitionAnimationController, Offset? anchorPoint, @@ -83,8 +85,9 @@ Future showBuzzModalBottomSheet({ top: false, left: false, right: false, + bottom: padBottom, child: Padding( - padding: const EdgeInsets.only(bottom: Grid.half), + padding: EdgeInsets.only(bottom: padBottom ? Grid.half : 0), child: Builder(builder: builder), ), ), diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index fed928c586..50ec3b2b6f 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -2863,7 +2863,8 @@ void main() { expect(find.byType(PairingPage), findsOneWidget); expect(find.byType(FallbackPairingQrScanner), findsNothing); expect(find.text('Scan a QR code'), findsOneWidget); - expect(find.text('Use pairing code'), findsOneWidget); + expect(find.text('Use pairing code'), findsNothing); + expect(find.byKey(const Key('developer-pairing-trigger')), findsOneWidget); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index 35f87c8f16..fdb32b4dde 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -8,6 +8,8 @@ import 'package:buzz/shared/hosted/hosted_flow.dart'; import 'package:buzz/shared/hosted/builderlab_account.dart'; import 'package:buzz/shared/hosted/hosted_community_connection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -18,6 +20,155 @@ void main() { testWidgets( 'cancel sign-in and resolve batch conflicts without replacing saved identities', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + // Keep secure-storage operations in the same test zone. + for (final reduced in [false, true]) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 640); + + FlutterSecureStorage.setMockInitialValues({}); + PendingDeepLinkNotifier.debugUriStreamOverride = + const Stream.empty(); + final f = HostedCommunitiesFixture(); + await f.initialize(widget: true); + f.expired = true; + + await tester.pumpWidget( + WidgetHelpers.testable( + disableAnimations: reduced, + child: const HostedAppGate( + authenticated: false, + child: OnboardingAppEntry(), + ), + overrides: [hostedFlowProvider.overrideWithValue(f.flow)], + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Other options')); + await tester.pumpAndSettle(); + tester.view.physicalSize = const Size(420, 900); + await tester.pumpAndSettle(); + final linkCard = find.ancestor( + of: find.text('Enter a community link').hitTestable(), + matching: find.byType(InkWell), + ); + expect(tester.getBottomRight(linkCard).dy, closeTo(876, 1)); + final scanCard = find.ancestor( + of: find.text('Scan a QR code').hitTestable(), + matching: find.byType(InkWell), + ); + final subtitle = find + .text('Scan a QR code or enter a community link to continue.') + .hitTestable(); + expect( + tester.getTopLeft(scanCard).dy - tester.getBottomLeft(subtitle).dy, + closeTo(24, 1), + ); + tester.view.physicalSize = const Size(320, 640); + await tester.pumpAndSettle(); + final optionTop = tester + .getTopLeft(find.text('Enter a community link').hitTestable()) + .dy; + final scanTop = tester.getTopLeft(scanCard).dy; + await tester.tap(find.text('Enter a community link').hitTestable()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 30)); + if (!reduced) { + // Keyboard setup must finish before any heading/surface movement. + expect( + tester + .getTopLeft(find.byKey(const Key('connection-options-motion'))) + .dy, + scanTop, + ); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'buzz/community_keyboard', + const StandardMethodCodec().encodeMethodCall( + const MethodCall('willShow'), + ), + (_) {}, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 30)); + final scanLayer = find.byKey(const Key('connection-options-motion')); + expect(tester.getTopLeft(scanLayer).dy, greaterThan(scanTop)); + final formOpacity = tester.widget( + find + .descendant( + of: find.byKey(const Key('community-form-motion')), + matching: find.byType(Opacity), + ) + .first, + ); + expect(formOpacity.opacity, greaterThan(0)); + final surface = find.byWidgetPredicate( + (widget) => widget is LayoutId && widget.id == 'surface', + ); + final action = find.byWidgetPredicate( + (widget) => widget is LayoutId && widget.id == 'action', + ); + expect(tester.getRect(surface), tester.getRect(action)); + } + await tester.pump(const Duration(milliseconds: 50)); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(tester.testTextInput.isVisible, isFalse); + expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + await tester.tap(find.text('Enter a community link').hitTestable()); + await tester.pump(); + // A hardware keyboard produces no software-keyboard notification. + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + expect( + tester + .getTopLeft(find.text('Enter a community link').hitTestable()) + .dy, + lessThan(optionTop - 100), + ); + expect(tester.testTextInput.isVisible, isTrue); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + final field = find.byKey(const Key('community-link-input')); + await tester.ensureVisible(field); + expect(field.hitTestable(), findsOneWidget); + expect( + tester + .widget( + find.descendant(of: field, matching: find.byType(EditableText)), + ) + .focusNode + .hasFocus, + isTrue, + ); + expect(tester.takeException(), isNull); + tester.view.viewInsets = const FakeViewPadding(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(tester.testTextInput.isVisible, isFalse); + expect(find.text('Use pairing code'), findsNothing); + final developerTrigger = find + .byKey(const Key('developer-pairing-trigger')) + .hitTestable(); + await tester.tap(developerTrigger); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(developerTrigger); + await tester.pumpAndSettle(); + expect( + find + .text('Paste the pairing code from your desktop app.') + .hitTestable(), + findsOneWidget, + ); + await tester.tap(find.text('Back').hitTestable()); + await tester.pumpAndSettle(); + expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + await f.dispose(); + tester.view.reset(); + } + debugDefaultTargetPlatformOverride = null; await tester.binding.setSurfaceSize(const Size(440, 956)); addTearDown(() => tester.binding.setSurfaceSize(null)); // Keep the process-wide secure-store queue in one widget-test zone. @@ -28,6 +179,7 @@ void main() { final f = HostedCommunitiesFixture(); await f.initialize(widget: true); f.rows = [f.row('one'), f.row('two')]; + f.expired = true; addTearDown(() async { PendingDeepLinkNotifier.debugUriStreamOverride = null; await f.dispose(); @@ -43,17 +195,61 @@ void main() { ), ); await tester.pumpAndSettle(); + expect( + find.text('Sign in with builderlab').hitTestable(), + findsOneWidget, + ); + expect(find.text('Enter a community link').hitTestable(), findsNothing); + expect(find.text('Scan a QR code'), findsNothing); + expect(find.text('Use pairing code'), findsNothing); + await tester.tap(find.text('Other options')); + await tester.pumpAndSettle(); + expect( + find.text('Enter a community link').hitTestable(), + findsOneWidget, + ); + expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + expect(find.text('Use pairing code').hitTestable(), findsNothing); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Enter a community link').hitTestable(), findsNothing); + await tester.tap(find.text('Other options')); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Other options').hitTestable(), findsOneWidget); + expect(find.text('Enter a community link').hitTestable(), findsNothing); + await tester.tap(find.text('Sign in with builderlab')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(f.browser.startedIntent, {'type': 'account'}); + expect( + find.bySemanticsLabel('Cancel sign-in').hitTestable(), + findsOneWidget, + ); + await tester.tap(find.bySemanticsLabel('Cancel sign-in').hitTestable()); + await tester.pumpAndSettle(); + expect( + find.text('Sign in with builderlab').hitTestable(), + findsOneWidget, + ); + f.expired = false; + await f.flow.signInAccount(openBrowser: false); + await tester.pumpAndSettle(); f.expired = true; await tester.tap(find.text('Continue with Builderlab')); await tester.pump(); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 300)); expect(f.flow.stage, HostedFlowStage.signIn); expect(f.browser.startedIntent, {'type': 'account'}); expect(find.byType(HostedConnectPage), findsNothing); - expect(find.text('Cancel sign-in').hitTestable(), findsOneWidget); - expect(find.text('Enter a community link'), findsOneWidget); + expect( + find.bySemanticsLabel('Cancel sign-in').hitTestable(), + findsOneWidget, + ); + expect(find.text('Enter a community link').hitTestable(), findsNothing); f.expired = false; - await tester.tap(find.text('Cancel sign-in').hitTestable()); + await tester.tap(find.bySemanticsLabel('Cancel sign-in').hitTestable()); await tester.pumpAndSettle(); expect(f.flow.stage, HostedFlowStage.idle); await tester.pumpAndSettle(); diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 450219f7f6..4c053b5896 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -4,7 +4,12 @@ import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'dart:async'; import 'dart:convert'; -import 'package:buzz/features/onboarding/hosted_profile_setup.dart'; +import 'package:buzz/features/profile/profile_setup_page.dart'; +import 'package:buzz/features/profile/profile_setup_avatar_editor.dart'; +import 'package:buzz/features/profile/profile_avatar_draft.dart'; +import 'package:buzz/shared/emoji/emoji_avatar.dart'; +import 'package:buzz/shared/widgets/playing_avatar_image.dart'; +import 'package:buzz/features/pairing/pairing_page.dart'; import 'package:buzz/onboarding_app_entry.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/hosted/builderlab_account.dart'; @@ -52,6 +57,73 @@ void main() { ); } + journey('system Back leaves community creation', (tester, f) async { + await f.initialize(); + await f.mount(tester); + await f.tap(tester, 'Create a community'); + expect( + tester.widget(find.byType(PairingPage)).creatingCommunity, + isTrue, + ); + await tester.binding.handlePopRoute(); + await f.settle(tester); + expect( + tester.widget(find.byType(PairingPage)).creatingCommunity, + isFalse, + ); + expect(find.text('Create a community').hitTestable(), findsOneWidget); + }); + + journey('retiring profile setup removes both profile and avatar routes', ( + tester, + f, + ) async { + await f.initialize(); + f.rows.add(f.row('one')); + await f.mount(tester); + await f.tap(tester, 'Continue with Builderlab'); + await tester.tap(find.byTooltip('Add profile photo')); + await f.settle(tester); + expect(find.byType(ProfileSetupAvatarEditor), findsOneWidget); + await f.link(tester, 'two'); + expect( + find.byType(ProfileSetupAvatarEditor, skipOffstage: false), + findsNothing, + ); + expect(find.text('Set up your profile', skipOffstage: false), findsNothing); + expect(f.profileWrites, isEmpty); + }); + + journey('fallback profile previews the accepted draft before saving', ( + tester, + f, + ) async { + await f.initialize(); + f.rows.add(f.row('one')); + await f.mount(tester); + await f.tap(tester, 'Continue with Builderlab'); + await tester.tap(find.byTooltip('Add profile photo')); + await f.settle(tester); + final url = emojiAvatarDataUrl('🐝', 0xFFFFE75C); + Navigator.of( + tester.element(find.byType(ProfileSetupAvatarEditor)), + ).pop(ProfileUrlAvatarDraft(url)); + await f.settle(tester); + expect( + tester + .widget(find.byType(PlayingAvatarImage)) + .imageUrl, + url, + ); + await tester.enterText( + find.byType(TextField).hitTestable(), + 'Draft Person', + ); + await f.tap(tester, 'Continue'); + expect(f.profileWrites, ['Draft Person']); + expect(f.profileAvatars, [url]); + }); + journey( 'QR credentials enter without an account when domain is not required', (tester, f) async { @@ -71,7 +143,7 @@ void main() { expect(find.text('Create a community'), findsNothing); expect(find.text('Enter a community link'), findsOneWidget); expect(find.text('Scan a QR code'), findsOneWidget); - expect(find.text('Use pairing code'), findsOneWidget); + expect(find.text('Use pairing code'), findsNothing); await f.tap(tester, 'Enter a community link'); expect( find.byKey(const Key('community-link-input')).hitTestable(), @@ -384,7 +456,17 @@ void main() { expect((await f.storage.loadAll()).single.starterSetupIncomplete, isTrue); expect(find.text('Try again').hitTestable(), findsOneWidget); await f.tap(tester, 'Try again'); - expect(find.byType(HostedProfileSetup), findsOneWidget); + expect( + find.byType(ProfileSetupPage, skipOffstage: false), + findsOneWidget, + ); + expect( + find.ancestor( + of: find.byType(TextField).hitTestable(), + matching: find.byType(BottomSheet), + ), + findsOneWidget, + ); expect(f.sent('/claim'), hasLength(1)); expect(f.recoveryScopes, hasLength(3)); expect( @@ -477,17 +559,20 @@ void main() { findsOneWidget, ); expect(find.text('Scan a QR code'), findsOneWidget); - expect(find.text('Use pairing 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. expect(find.byType(CheckboxListTile).hitTestable(), findsNothing); - expect(find.byType(HostedProfileSetup), findsOneWidget); + expect( + find.byType(ProfileSetupPage, skipOffstage: false), + findsOneWidget, + ); final firstSaved = (await f.storage.loadAll()).firstWhere( (c) => c.name == 'one', ); Navigator.of( - tester.element(find.byType(HostedProfileSetup)), + tester.element(find.byType(ProfileSetupPage, skipOffstage: false)), rootNavigator: true, ).pop(); await f.settle(tester); @@ -528,7 +613,10 @@ void main() { await f.tap(tester, 'Continue with Builderlab'); // One discovered community is admitted automatically. expect(f.profileWrites, isEmpty); - expect(find.byType(HostedProfileSetup), findsNothing); + expect( + find.byType(ProfileSetupPage, skipOffstage: false), + findsNothing, + ); final saved = (await f.storage.loadAll()).single; expect(saved.nsec, f.keys.nsec); expect(saved.pubkey, f.keys.public); @@ -728,7 +816,10 @@ void main() { expect(await f.storage.loadAll(), isEmpty); await f.restart(tester); await f.tap(tester, 'Start login again'); - expect(find.byType(HostedProfileSetup), findsOneWidget); + expect( + find.byType(ProfileSetupPage, skipOffstage: false), + findsOneWidget, + ); await tester.enterText(find.byType(TextField).hitTestable(), 'Creator'); await f.tap(tester, 'Continue'); final saved = (await f.storage.loadAll()).single; diff --git a/mobile/test/features/onboarding/native_entry_fallback_test.dart b/mobile/test/features/onboarding/native_entry_fallback_test.dart new file mode 100644 index 0000000000..f1acb76a38 --- /dev/null +++ b/mobile/test/features/onboarding/native_entry_fallback_test.dart @@ -0,0 +1,58 @@ +import 'package:buzz/features/onboarding/self_hosted_entry_sheet.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:buzz/shared/hosted/self_hosted_entry.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/widget_helpers.dart'; + +class _AvailableEntry extends SelfHostedEntry { + @override + Future<({List choices, bool complete})> + choices() async => (choices: const [], complete: true); +} + +void main() { + testWidgets( + 'native presentation failure opens the usable Flutter join sheet', + (tester) async { + FlutterSecureStorage.setMockInitialValues({}); + const channel = MethodChannel('buzz/community_entry'); + final messenger = tester.binding.defaultBinaryMessenger; + var presentations = 0; + messenger.setMockMethodCallHandler(channel, (_) async { + presentations++; + throw PlatformException(code: 'presentation_failed'); + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + selfHostedEntryProvider.overrideWithValue(_AvailableEntry()), + ], + child: Builder( + builder: (context) => TextButton( + onPressed: () => showSelfHostedEntry( + context, + relay: 'wss://example.com', + name: 'Example', + ), + child: const Text('Open join'), + ), + ), + ), + ); + await tester.tap(find.text('Open join')); + await tester.pumpAndSettle(); + expect(presentations, 1); + expect(tester.takeException(), isNull); + expect(find.byType(SelfHostedEntrySheet), findsOneWidget); + expect(find.text('Import a Nostr key').hitTestable(), findsOneWidget); + Navigator.of(tester.element(find.byType(SelfHostedEntrySheet))).pop(); + await tester.pumpAndSettle(); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); +} diff --git a/mobile/test/features/onboarding/onboarding_entry_routes_test.dart b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart index dcf2b90f50..701def7eb5 100644 --- a/mobile/test/features/onboarding/onboarding_entry_routes_test.dart +++ b/mobile/test/features/onboarding/onboarding_entry_routes_test.dart @@ -10,6 +10,7 @@ import 'package:buzz/hosted_app_gate.dart'; import 'package:buzz/shared/hosted/hosted_flow_provider.dart'; import 'package:buzz/onboarding_app_entry.dart'; import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/identity_transfer/nsec_import_sheet.dart'; import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'package:buzz/shared/hosted/builderlab_account.dart'; import 'package:buzz/shared/hosted/hosted_flow.dart'; @@ -46,6 +47,7 @@ void main() { }); var probes = 0; final inventoryStorage = _EnumerationStorage(); + Completer? pendingProbe; await tester.pumpWidget( WidgetHelpers.testable( disableAnimations: true, @@ -57,6 +59,7 @@ void main() { accountStore: AccountIdentityStore(storage: inventoryStorage), probe: (_) async { probes++; + await pendingProbe?.future; }, ), ), @@ -92,7 +95,12 @@ void main() { } Future pair() async { - await tester.tap(find.text('Use pairing code').hitTestable()); + 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); await tester.pumpAndSettle(); await tester.enterText( find.byType(TextField).hitTestable(), @@ -225,9 +233,68 @@ void main() { await tester.tap(find.text('Continue').hitTestable()); await tester.pumpAndSettle(); expect(find.text('Import a Nostr key').hitTestable(), findsOneWidget); - await tester.tap(find.text('Previous device identity').hitTestable()); + final joinRoute = ModalRoute.of( + tester.element(find.byType(SelfHostedEntrySheet)), + ); + 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.text('Import identity'), findsOneWidget); + expect( + ModalRoute.of(tester.element(find.byType(NsecImportSheet))), + same(joinRoute), + ); + expect( + tester.getSize(find.byType(SelfHostedEntrySheet)).height, + greaterThan(joinHeight), + ); + await tester.tap(find.byTooltip('Close sheet')); + await tester.pumpAndSettle(); + expect(find.byType(SelfHostedEntrySheet), findsOneWidget); + expect(find.byType(NsecImportSheet), findsNothing); + expect(find.text('Join community'), findsOneWidget); + expect( + tester.getSize(find.byType(SelfHostedEntrySheet)).height, + joinHeight, + ); + await tester.tap(find.text('Import a Nostr key').hitTestable()); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(NsecImportSheet), + matching: find.byType(TextField), + ), + f.keys.nsec, + ); + await tester.tap(find.text('Preview identity')); + await tester.pumpAndSettle(); + expect(probes, 0); + pendingProbe = Completer(); + await tester.tap(find.text('Import this identity')); await tester.pumpAndSettle(); expect(probes, 1); + await tester.tap(find.byTooltip('Close sheet')); + await tester.pumpAndSettle(); + pendingProbe.complete(); + await tester.pumpAndSettle(); + expect(find.text('Join community'), findsOneWidget); + expect(await container.read(communityStorageProvider).loadAll(), isEmpty); + pendingProbe = null; + await tester.tap(find.text('Import a Nostr key').hitTestable()); + await tester.pumpAndSettle(); + await tester.enterText( + find.descendant( + of: find.byType(NsecImportSheet), + matching: find.byType(TextField), + ), + f.keys.nsec, + ); + await tester.tap(find.text('Preview identity')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Import this identity')); + await tester.pumpAndSettle(); + expect(probes, 2); expect(find.byType(SelfHostedEntrySheet), findsNothing); expect(find.byType(OnboardingAppEntry), findsNothing); expect(find.text('Add community').hitTestable(), findsOneWidget); @@ -251,7 +318,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(OnboardingAppEntry), findsNothing); expect(find.text('Add community').hitTestable(), findsOneWidget); - expect(probes, 1); + expect(probes, 2); expect( await container.read(communityStorageProvider).loadActiveId(), saved.single.id, diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index c575cde5f5..c5de9ccb56 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -689,8 +689,8 @@ void main() { expect(confirmTopLeft.dx, Grid.sm); expect(tester.getSize(confirmFinder).width, scaffoldWidth - Grid.sm * 2); expect(tester.getSize(cancelFinder).width, scaffoldWidth - Grid.sm * 2); - expect(tester.getSize(confirmFinder).height, 48); - expect(tester.getSize(cancelFinder).height, 48); + expect(tester.getSize(confirmFinder).height, 52); + expect(tester.getSize(cancelFinder).height, 52); expect( find.textContaining( 'Only continue if you started this pairing from your desktop.', diff --git a/mobile/test/features/profile/profile_setup_avatar_editor_test.dart b/mobile/test/features/profile/profile_setup_avatar_editor_test.dart new file mode 100644 index 0000000000..3c713fe445 --- /dev/null +++ b/mobile/test/features/profile/profile_setup_avatar_editor_test.dart @@ -0,0 +1,100 @@ +import 'package:buzz/features/profile/profile_setup_avatar_editor.dart'; +import 'package:buzz/features/profile/profile_avatar_draft.dart'; +import 'package:buzz/shared/emoji/emoji_avatar.dart'; +import 'dart:typed_data'; +import 'package:buzz/features/profile/profile_avatar_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + testWidgets( + 'accepted animated draft can reopen and finish without recapture', + (tester) async { + final draft = ProfileAnimatedAvatarDraft( + animation: Uint8List(0), + poster: Uint8List(0), + ); + ProfileAvatarDraft? accepted; + Rect? nativePreview; + Future open(ProfileAvatarDraft? initial) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: ProfileSetupAvatarEditor( + key: UniqueKey(), + initialDraft: initial, + onPreviewReady: (rect) async { + nativePreview = rect; + }, + onFinished: (value) => accepted = value, + ), + ), + ), + ); + await tester.pump(); + } + + await open(null); + tester + .widget(find.byType(ProfileAvatarEditor)) + .onModeChanged(ProfileAvatarMode.animated); + await tester.pump(); + tester + .widget(find.byType(ProfileAvatarEditor)) + .onAnimatedPrepareChanged + .call(() async => draft); + await tester.pump(); + await tester.tap(find.text('Done')); + await tester.pump(); + expect(accepted, same(draft)); + nativePreview = null; + await open(accepted); + expect(nativePreview, isNotNull); + expect(nativePreview!.width, greaterThan(0)); + accepted = null; + expect(find.byTooltip('Back to profile'), findsOneWidget); + await tester.tap(find.text('Done')); + await tester.pump(); + expect(accepted, same(draft)); + tester + .widget(find.byType(ProfileAvatarEditor)) + .onModeChanged(ProfileAvatarMode.image); + await tester.pump(); + expect( + tester + .widget(find.widgetWithText(TextButton, 'Done')) + .onPressed, + isNull, + ); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + + testWidgets('avatar draft editor lays out within a full height sheet', ( + tester, + ) async { + Rect? previewRect; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: ProfileSetupAvatarEditor( + onPreviewReady: (rect) async { + previewRect = rect; + }, + initialDraft: ProfileUrlAvatarDraft( + emojiAvatarDataUrl('🐝', 0xFFFFE75C), + ), + ), + ), + ), + ); + await tester.pump(); + expect(find.text('Done'), findsOneWidget); + expect(previewRect, isNotNull); + expect(previewRect!.width, greaterThan(0)); + expect(previewRect!.height, greaterThan(0)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); +} diff --git a/mobile/test/features/profile/profile_setup_native_name_test.dart b/mobile/test/features/profile/profile_setup_native_name_test.dart new file mode 100644 index 0000000000..cc35f9b4aa --- /dev/null +++ b/mobile/test/features/profile/profile_setup_native_name_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:buzz/features/profile/profile_setup_page.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.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('native profile saves 100 graphemes and rejects 101', ( + tester, + ) async { + FlutterSecureStorage.setMockInitialValues({}); + final fixture = HostedCommunitiesFixture(); + await fixture.initialize(widget: true); + addTearDown(fixture.dispose); + for (final grapheme in ['πŸ˜€', 'e\u0301', 'πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦']) { + for (final count in [100, 101]) { + const channel = MethodChannel('buzz/profile_setup'); + final messenger = tester.binding.defaultBinaryMessenger; + final presented = Completer>(); + final dismissed = Completer(); + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'present') { + presented.complete(call.arguments as Map); + return dismissed.future; + } + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final writes = []; + final pending = fixture.flow.requestProfile( + (name) async => writes.add(name), + ); + await tester.pumpWidget( + WidgetHelpers.testable(child: ProfileSetupPage(flow: fixture.flow)), + ); + await tester.pumpAndSettle(); + final configuration = await presented.future; + final name = List.filled(count, grapheme).join(); + final response = Completer>(); + messenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('save', { + 'session': configuration['session'], + 'name': name, + }), + ), + (data) => response.complete( + const StandardMethodCodec().decodeEnvelope(data!) as Map, + ), + ); + expect(await response.future, {'saved': count == 100}); + expect(writes, count == 100 ? [name] : isEmpty); + dismissed.complete(count == 100 ? 'saved' : 'skip'); + await tester.pumpAndSettle(); + await pending; + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } + } + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); +} diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index 60ba65fe74..db10fd426d 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -48,6 +48,7 @@ class HostedJourneyFixture { final probes = []; String? refusedSelfHostedPubkey; final profileWrites = []; + final profileAvatars = []; final recoveryScopes = []; final rows = >[]; final cloudKeys = []; @@ -492,6 +493,12 @@ class _Profile extends ProfileNotifier { Future updateDisplayName(String name) async { fixture.profileWrites.add(name); } + + @override + Future updateSetup(String name, String? avatarUrl) async { + fixture.profileWrites.add(name); + fixture.profileAvatars.add(avatarUrl); + } } // This boundary proves pending-flag/retry ownership, not relay memberships. diff --git a/mobile/test/shared/hosted/hosted_profile_offer_test.dart b/mobile/test/shared/hosted/hosted_profile_offer_test.dart index 2603735490..1ce734c13d 100644 --- a/mobile/test/shared/hosted/hosted_profile_offer_test.dart +++ b/mobile/test/shared/hosted/hosted_profile_offer_test.dart @@ -158,6 +158,48 @@ void main() { }, ); + test( + 'profile draft retries without completing and skip does not prepare media', + () async { + var prepares = 0; + var attempts = 0; + final pending = f.flow.requestProfile( + (_) async {}, + saveDraft: (name, prepare) async { + await prepare?.call(); + if (++attempts == 1) throw StateError('offline'); + writes.add(name); + }, + ); + final save = f.flow.saveProfileDraft!; + Future prepare() async { + prepares++; + return 'https://media.example/photo'; + } + + await expectLater(save('Name', prepare), throwsStateError); + expect(f.flow.stage, HostedFlowStage.profile); + await save('Name', prepare); + await pending; + expect(writes, ['Name']); + expect(prepares, 2); + final skipped = f.flow.requestProfile( + (_) async {}, + saveDraft: (_, prepare) async { + await prepare?.call(); + }, + ); + final stale = f.flow.saveProfileDraft!; + f.flow.finishProfile(); + await skipped; + await expectLater( + stale('Old', prepare), + throwsA(AccountFailure.unavailable), + ); + expect(prepares, 2); + }, + ); + for (final cancelAdmission in [false, true]) { test( 'leave rejects captured save (cancel admission: $cancelAdmission)', diff --git a/mobile/test/shared/identity_transfer/native_community_entry_test.dart b/mobile/test/shared/identity_transfer/native_community_entry_test.dart new file mode 100644 index 0000000000..1ccc0763ca --- /dev/null +++ b/mobile/test/shared/identity_transfer/native_community_entry_test.dart @@ -0,0 +1,174 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/identity_transfer/native_community_entry.dart'; +import 'package:buzz/shared/hosted/device_identity_source.dart'; +import 'package:nostr/nostr.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('buzz/community_entry'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late Completer dismissed; + late Completer> presented; + setUp(() { + dismissed = Completer(); + presented = Completer>(); + messenger.setMockMethodCallHandler(channel, (call) async { + presented.complete(call.arguments as Map); + return dismissed.future; + }); + }); + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + Future?> action( + String method, + Map args, + ) async { + final result = Completer?>(); + messenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall(MethodCall(method, args)), + (data) => result.complete( + const StandardMethodCodec().decodeEnvelope(data!) as Map?, + ), + ); + return result.future; + } + + for (final code in [ + 'presentation_failed', + 'invalid_arguments', + 'already_presented', + ]) { + test('native $code preserves the presentation contract', () async { + messenger.setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: code); + }); + final result = NativeCommunityEntry.show( + name: 'Preview', + dark: false, + primaryArgb: 0xFF007AFF, + choices: const [], + connect: (_, _, _, _) async => fail('Must not connect'), + ); + if (code == 'presentation_failed') { + expect(await result, isNull); + // A failed presentation must not leave the singleton busy. + messenger.setMockMethodCallHandler(channel, (_) async => true); + expect( + await NativeCommunityEntry.show( + name: 'Preview', + dark: false, + primaryArgb: 0xFF007AFF, + choices: const [], + connect: (_, _, _, _) async {}, + ), + isTrue, + ); + } else { + await expectLater( + result, + throwsA(isA().having((e) => e.code, 'code', code)), + ); + } + }); + } + + test( + 'invite confirmation delegates claiming and preserves retry errors', + () async { + var claims = 0; + final shown = NativeCommunityEntry.show( + name: 'preview.example.com', + dark: false, + primaryArgb: 0xFF000000, + choices: const [], + connect: (_, _, _, _) async => fail('Must use invite claiming'), + confirmInvite: () async => + ++claims == 1 ? {'error': 'Try again'} : {'joined': true}, + ); + final config = await presented.future; + expect(config['invite'], true); + final args = {'session': config['session']}; + expect((await action('join', args))?['error'], 'Try again'); + expect((await action('join', args))?['joined'], true); + dismissed.complete(true); + expect(await shown, true); + }, + ); + + test( + 'native import requires preview and confirmation before connecting', + () async { + final keys = Keys('1'.padLeft(64, '0')); + var joins = 0; + final shown = NativeCommunityEntry.show( + name: 'Preview', + dark: false, + primaryArgb: 0xFF007AFF, + choices: [], + connect: (_, nsec, create, current) async { + expect(nsec, keys.nsec); + expect(create, false); + expect(current(), true); + joins++; + }, + ); + final config = await presented.future; + final args = { + 'session': config['session'], + 'nsec': keys.nsec, + }; + expect((await action('join', args))?['error'], isNotNull); + expect(joins, 0); + expect((await action('preview', args))?['npub'], startsWith('npub1')); + expect(joins, 0); + expect((await action('join', args))?['joined'], true); + expect(joins, 1); + dismissed.complete(true); + expect(await shown, true); + }, + ); + + test( + 'returning to choices fences an in-flight join and allows retry', + () async { + final pending = Completer(); + final began = Completer(); + var calls = 0; + var staleWasCurrent = true; + final shown = NativeCommunityEntry.show( + name: 'Preview', + dark: false, + primaryArgb: 0xFF007AFF, + choices: const [DeviceIdentityChoice('pubkey', 'Saved')], + connect: (_, _, _, current) async { + calls++; + if (calls == 1) { + began.complete(); + await pending.future; + staleWasCurrent = current(); + } + }, + ); + final config = await presented.future; + final args = { + 'session': config['session'], + 'pubkey': 'pubkey', + }; + final first = action('join', args); + await began.future; + await action('cancel', args); + pending.complete(); + expect((await first)?['cancelled'], true); + expect(staleWasCurrent, false); + expect((await action('join', args))?['joined'], true); + expect(calls, 2); + dismissed.complete(true); + expect(await shown, true); + }, + ); +} diff --git a/mobile/test/shared/identity_transfer/nsec_import_sheet_test.dart b/mobile/test/shared/identity_transfer/nsec_import_sheet_test.dart index 5e87ef648a..24d0d6e9ac 100644 --- a/mobile/test/shared/identity_transfer/nsec_import_sheet_test.dart +++ b/mobile/test/shared/identity_transfer/nsec_import_sheet_test.dart @@ -1,6 +1,7 @@ import 'package:buzz/shared/hosted/account_identity_store.dart'; import 'package:buzz/shared/identity_transfer/nsec_import_sheet.dart'; import 'package:buzz/shared/utils/string_utils.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -8,32 +9,78 @@ import 'package:nostr/nostr.dart' as nostr; void main() { final keys = nostr.Keys('1'.padLeft(64, '0')); - testWidgets( - 'paste requires public preview then confirmation before secure save', - (tester) async { - String? imported; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: NsecImportSheet( - onImport: (nsec) async { - imported = nsec; - }, + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + testWidgets( + '$platform paste requires public preview then confirmation before secure save', + (tester) async { + String? imported; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showNsecImportSheet( + context, + onImport: (nsec) async { + imported = nsec; + }, + ), + child: const Text('Open import'), + ), + ), ), ), + ); + await tester.tap(find.text('Open import')); + await tester.pumpAndSettle(); + final input = platform == TargetPlatform.iOS + ? find.byType(CupertinoTextField) + : find.byType(TextField); + if (platform == TargetPlatform.iOS) { + final field = tester.widget(input); + expect(field.obscureText, isTrue); + expect(field.autocorrect, isFalse); + expect(field.enableIMEPersonalizedLearning, isFalse); + } + await tester.enterText(input, keys.nsec); + await tester.tap(find.text('Preview identity')); + await tester.pumpAndSettle(); + expect(find.text(fullNpub(keys.public)!), findsOneWidget); + expect(imported, isNull); + await tester.ensureVisible(find.text('Import this identity')); + await tester.tap(find.text('Import this identity')); + await tester.pumpAndSettle(); + expect(imported, keys.nsec); + expect(find.text('Open import'), findsOneWidget); + expect(find.text('Private key (nsec)'), findsNothing); + }, + variant: TargetPlatformVariant({platform}), + ); + } + + testWidgets('iOS import waits for its containing sheet before focusing', ( + tester, + ) async { + Future show(bool autofocus) => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NsecImportSheet(autofocus: autofocus, onImport: (_) async {}), ), - ); - await tester.enterText(find.byType(TextField), keys.nsec); - await tester.tap(find.text('Preview identity')); - await tester.pumpAndSettle(); - expect(find.text(fullNpub(keys.public)!), findsOneWidget); - expect(imported, isNull); - await tester.tap(find.text('Import this identity')); - await tester.pumpAndSettle(); - expect(imported, keys.nsec); - await tester.pump(); - }, - ); + ), + ); + await show(false); + await tester.pump(); + expect( + tester.widget(find.byType(EditableText)).focusNode.hasFocus, + isFalse, + ); + await show(true); + await tester.pump(); + expect( + tester.widget(find.byType(EditableText)).focusNode.hasFocus, + isTrue, + ); + }, variant: TargetPlatformVariant({TargetPlatform.iOS})); testWidgets('file import previews and occupied identity stays unchanged', ( tester, diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index fd1b650cb7..3c4cb0a5ae 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/hosted/hosted_environment.dart'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:buzz/shared/community/paired_community_landing.dart'; @@ -100,7 +101,18 @@ void main() { ); await tester.pump(); expect(find.bySemanticsLabel('Buzz'), findsOneWidget); - expect(find.text('Scan a QR code'), findsOneWidget); + expect( + find.text('Sign in with builderlab'), + HostedEnvironment.enabled ? findsOneWidget : findsNothing, + ); + expect( + find.text('Other options'), + HostedEnvironment.enabled ? findsOneWidget : findsNothing, + ); + expect( + find.text('Scan a QR code'), + HostedEnvironment.enabled ? findsNothing : findsOneWidget, + ); }); }