diff --git a/Flipcash/Core/Screens/Onboarding/IntroScreen.swift b/Flipcash/Core/Screens/Onboarding/IntroScreen.swift index 73c4b8d1..659ad899 100644 --- a/Flipcash/Core/Screens/Onboarding/IntroScreen.swift +++ b/Flipcash/Core/Screens/Onboarding/IntroScreen.swift @@ -123,18 +123,16 @@ private struct IntroScreenContent: View { LoginScreen() case .accessKey: AccessKeyScreen(viewModel: viewModel) + .navigationBarBackButtonHidden(true) case .accessKeyHelp: AccessKeyHelpScreen() case .pushNotifications: NotificationPermissionScreen(viewModel: viewModel) case .pushNotificationsDenied: NotificationPermissionDeniedScreen(viewModel: viewModel) - case .phoneVerification: - OnboardingPhoneVerificationStep(viewModel: viewModel) - case .confirmPhoneNumberCode: - if let phoneVM = viewModel.phoneVerificationViewModel { - ConfirmPhoneScreen(viewModel: phoneVM) - .navigationTitle("Connect Phone Number") + case .displayName: + if let nameVM = viewModel.nameViewModel { + OnboardingNameScreen(viewModel: nameVM) .navigationBarBackButtonHidden(true) } } @@ -143,24 +141,6 @@ private struct IntroScreenContent: View { } } -// MARK: - Phone verification step - - -/// Wrapper for the onboarding phone entry screen. Reads the shared -/// `PhoneVerificationViewModel` from `OnboardingViewModel` so the -/// follow-up `ConfirmPhoneScreen` destination binds the same instance. -private struct OnboardingPhoneVerificationStep: View { - - let viewModel: OnboardingViewModel - - var body: some View { - if let phoneVM = viewModel.phoneVerificationViewModel { - EnterPhoneScreen(viewModel: phoneVM) - .navigationTitle("Connect Phone Number") - .navigationBarBackButtonHidden(true) - } - } -} - // MARK: - Previews - #Preview { diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingNameScreen.swift b/Flipcash/Core/Screens/Onboarding/OnboardingNameScreen.swift new file mode 100644 index 00000000..d09d79ec --- /dev/null +++ b/Flipcash/Core/Screens/Onboarding/OnboardingNameScreen.swift @@ -0,0 +1,88 @@ +// +// OnboardingNameScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The onboarding display-name step, entered after the access key. It is +/// mandatory — the surrounding `navigationDestination` hides the back button — +/// so the account always has a name for tips and chat before reaching the app. +struct OnboardingNameScreen: View { + + @Bindable private var viewModel: OnboardingNameViewModel + + @FocusState private var isNameFocused: Bool + + /// Shown only once the limit is close enough to explain a disabled Next. + private static let countdownThreshold = 10 + + // MARK: - Init - + + init(viewModel: OnboardingNameViewModel) { + self.viewModel = viewModel + } + + // MARK: - Body - + + var body: some View { + Background(color: .backgroundMain) { + VStack(alignment: .leading, spacing: 0) { + Text("What is your name?") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .padding(.top, 20) + + TextField("Your Name", text: $viewModel.displayName) + .font(.appDisplayMedium) + .foregroundStyle(Color.textMain) + .focused($isNameFocused) + .textContentType(.name) + .submitLabel(.next) + .onSubmit(viewModel.submit) + .padding(.top, 32) + .disabled(viewModel.isSubmitting) + + Spacer() + + if viewModel.remainingCharacters < Self.countdownThreshold { + Text("\(viewModel.remainingCharacters) characters") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 12) + } + + Button(action: viewModel.submit) { + if viewModel.isSubmitting { + ProgressView().progressViewStyle(.circular) + } else { + Text("Next") + } + } + .buttonStyle(.filled) + .disabled(viewModel.validatedDisplayName == nil || viewModel.isSubmitting) + .accessibilityIdentifier("onboarding-name-next-button") + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .navigationBarTitleDisplayMode(.inline) + .dialog(item: $viewModel.dialogItem) + .onAppear { isNameFocused = true } + } +} + +// MARK: - Previews - + +#Preview { + NavigationStack { + OnboardingNameScreen( + viewModel: OnboardingNameViewModel(owner: .mock, flipClient: .mock) + ) + } + .injectingEnvironment(from: .mock) +} diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingNameViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingNameViewModel.swift new file mode 100644 index 00000000..e9223a4d --- /dev/null +++ b/Flipcash/Core/Screens/Onboarding/OnboardingNameViewModel.swift @@ -0,0 +1,90 @@ +// +// OnboardingNameViewModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +private let logger = Logger(label: "flipcash.onboarding-name") + +/// Backs the onboarding display-name step. Collects and submits the name the +/// account needs for tips and chat, then hands control back via ``onComplete``. +/// +/// This mirrors the tip-card `ProfileNameScreen`, but the tip-card screen is +/// session-scoped (it reads `SessionContainer`/`AppRouter`); onboarding runs +/// pre-login, so this owns its own submission against the in-flight owner. +@Observable +@MainActor +final class OnboardingNameViewModel { + + var displayName: String = "" + var dialogItem: DialogItem? + + private(set) var isSubmitting: Bool = false + + @ObservationIgnored private let flipClient: FlipClient + @ObservationIgnored private let owner: KeyPair + @ObservationIgnored private let validator = DisplayNameValidator() + + /// Fires once the name is saved; the onboarding flow advances from here. + @ObservationIgnored var onComplete: (@MainActor () -> Void)? + + // MARK: - Init - + + init(owner: KeyPair, flipClient: FlipClient) { + self.owner = owner + self.flipClient = flipClient + } + + // MARK: - Derived state - + + /// The name accepted by `SetDisplayName`, or nil while the input is invalid. + /// This exact string is what gets submitted. + var validatedDisplayName: String? { + validator.validate(displayName) + } + + var remainingCharacters: Int { + validator.remaining(in: displayName) + } + + // MARK: - Submit - + + func submit() { + guard let name = validatedDisplayName, !isSubmitting else { return } + + isSubmitting = true + Task { + defer { isSubmitting = false } + + do { + try await flipClient.setDisplayName(name, owner: owner) + onComplete?() + + } catch ErrorProfile.moderated(let category) { + logger.info("Display name moderation denied", metadata: ["category": "\(category)"]) + dialogItem = .error( + title: "This Name is Not Allowed", + subtitle: "Try a different name" + ) + + } catch ErrorProfile.invalidDisplayName { + logger.info("Display name rejected as invalid") + dialogItem = .error( + title: "This Name Isn't Valid", + subtitle: "Try a different name" + ) + + } catch { + logger.error("Failed to set display name", metadata: ["error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to set display name during onboarding") + dialogItem = .error( + title: "Couldn't Save Your Name", + subtitle: "Try again" + ) + } + } + } +} diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index 249d2225..3cb97002 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -24,10 +24,9 @@ class OnboardingViewModel { @ObservationIgnored private let sessionAuthenticator: SessionAuthenticator @ObservationIgnored private var initializedAccount: InitializedAccount? - /// Built on first call to ``navigateToPhoneVerification``; shared - /// between the `EnterPhoneScreen` and `ConfirmPhoneScreen` - /// destinations so input state survives the push. - var phoneVerificationViewModel: PhoneVerificationViewModel? + /// Built on first call to ``navigateToDisplayName``; the display-name step + /// runs pre-login, so it owns its submission against the in-flight owner. + var nameViewModel: OnboardingNameViewModel? // MARK: - Init - @@ -52,7 +51,7 @@ class OnboardingViewModel { func createAccountAction() { inflightMnemonic = MnemonicPhrase.generate(.words12) - phoneVerificationViewModel = nil + nameViewModel = nil navigateToAccessKey() @@ -122,38 +121,18 @@ class OnboardingViewModel { accessKeyButtonState = .success try await Task.delay(milliseconds: 500) - // Phone verification only exists to power Send; show it when Send is - // available for this account, otherwise advance straight to the next step. - if await shouldOfferPhoneVerification() { - navigateToPhoneVerification() - } else { - await advanceFromPhoneVerificationStep() - } + // Every new account needs a display name for tips and chat, so collect + // one now — before the push-permission prompt. (Phone verification is + // no longer part of onboarding; a phone can still be connected later.) + navigateToDisplayName() try await Task.delay(milliseconds: 500) // Delay deferred state change } - /// Whether to collect a phone number during onboarding, decided by the - /// server's `enablePhoneNumberSend`. The fetch is time-boxed so a slow connection can't stall onboarding; - /// the step is skipped if the account isn't known yet, the fetch times out, or - /// it fails — a phone can still be connected later from the Send sheet. - private func shouldOfferPhoneVerification() async -> Bool { - guard let userID = initializedAccount?.userID else { - return false - } - - let flags = try? await container.flipClient.fetchUserFlags( - userID: userID, - owner: inflightMnemonic.solanaKeyPair(), - timeout: 5 - ) - - return flags?.enablePhoneNumberSend == true - } - - /// Advances past the phone step: requests push permission when undetermined, - /// otherwise finishes login. Contacts access is requested later, from Send. - private func advanceFromPhoneVerificationStep() async { + /// Advances past the display-name step: requests push permission when + /// undetermined, otherwise finishes login. Contacts access is requested + /// later, in-app. + private func advanceFromNameStep() async { let pushStatus = await PushController.fetchStatus() switch pushStatus { case .notDetermined: @@ -290,25 +269,18 @@ class OnboardingViewModel { path.append(.pushNotificationsDenied) } - func navigateToPhoneVerification() { - if phoneVerificationViewModel == nil { - let vm = PhoneVerificationViewModel( + func navigateToDisplayName() { + if nameViewModel == nil { + let vm = OnboardingNameViewModel( owner: inflightMnemonic.solanaKeyPair(), flipClient: container.flipClient, ) - vm.onCodeRequested = { [weak self] in - self?.navigateToConfirmPhoneCode() - } - vm.onVerified = { [weak self] in - Task { await self?.advanceFromPhoneVerificationStep() } + vm.onComplete = { [weak self] in + Task { await self?.advanceFromNameStep() } } - phoneVerificationViewModel = vm + nameViewModel = vm } - path.append(.phoneVerification) - } - - func navigateToConfirmPhoneCode() { - path.append(.confirmPhoneNumberCode) + path.append(.displayName) } } @@ -320,8 +292,7 @@ nonisolated enum OnboardingPath { case login case accessKey case accessKeyHelp - case phoneVerification - case confirmPhoneNumberCode + case displayName case pushNotifications case pushNotificationsDenied } diff --git a/FlipcashTests/OnboardingNameViewModelTests.swift b/FlipcashTests/OnboardingNameViewModelTests.swift new file mode 100644 index 00000000..5e793fa3 --- /dev/null +++ b/FlipcashTests/OnboardingNameViewModelTests.swift @@ -0,0 +1,34 @@ +// +// OnboardingNameViewModelTests.swift +// FlipcashTests +// + +import Testing +@testable import Flipcash +import FlipcashCore + +@MainActor +@Suite("OnboardingNameViewModel") +struct OnboardingNameViewModelTests { + + @Test("A blank name is invalid, so Next stays gated") + func blankName_isInvalid() { + let viewModel = OnboardingNameViewModel(owner: .mock, flipClient: .mock) + viewModel.displayName = " " + #expect(viewModel.validatedDisplayName == nil) + } + + @Test("A name is trimmed of surrounding whitespace and accepted") + func name_isTrimmedAndAccepted() { + let viewModel = OnboardingNameViewModel(owner: .mock, flipClient: .mock) + viewModel.displayName = " Ada " + #expect(viewModel.validatedDisplayName == "Ada") + } + + @Test("Remaining characters counts down from the scalar limit") + func remainingCharacters_countsDown() { + let viewModel = OnboardingNameViewModel(owner: .mock, flipClient: .mock) + viewModel.displayName = "Ada" + #expect(viewModel.remainingCharacters == DisplayNameValidator.maxScalars - 3) + } +} diff --git a/FlipcashUITests/Regression/GiveRegressionTests.swift b/FlipcashUITests/Regression/GiveRegressionTests.swift index 6944e1da..f6cf7675 100644 --- a/FlipcashUITests/Regression/GiveRegressionTests.swift +++ b/FlipcashUITests/Regression/GiveRegressionTests.swift @@ -22,7 +22,7 @@ final class GiveRegressionTests: BaseUITestCase { waitAndTap(app.buttons["Create a New Account"]) waitAndTap(app.buttons["Wrote the 12 Words Down Instead?"]) waitAndTap(app.buttons["Yes, I Wrote Them Down"]) - allowPhoneVerificationIfNeeded() + enterDisplayNameIfNeeded() allowPushNotificationsIfNeeded() assertMainScreenReached() diff --git a/FlipcashUITests/Smoke/CreateAccountSmokeTests.swift b/FlipcashUITests/Smoke/CreateAccountSmokeTests.swift index b2e52cf0..5fa22aad 100644 --- a/FlipcashUITests/Smoke/CreateAccountSmokeTests.swift +++ b/FlipcashUITests/Smoke/CreateAccountSmokeTests.swift @@ -23,8 +23,8 @@ final class CreateAccountSmokeTests: BaseUITestCase { let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") waitUntilHittableAndTap(springboard.buttons["Allow"]) - // Onboarding phone verification — forced for new registrations. - allowPhoneVerificationIfNeeded() + // Onboarding display name — mandatory for new registrations. + enterDisplayNameIfNeeded() // Push notification permission screen (may be skipped if already granted) allowPushNotificationsIfNeeded() @@ -43,8 +43,8 @@ final class CreateAccountSmokeTests: BaseUITestCase { // Confirmation dialog: "Are You Sure?" waitAndTap(app.buttons["Yes, I Wrote Them Down"]) - // Onboarding phone verification — forced for new registrations. - allowPhoneVerificationIfNeeded() + // Onboarding display name — mandatory for new registrations. + enterDisplayNameIfNeeded() // Push notification permission screen (may be skipped if already granted) allowPushNotificationsIfNeeded() diff --git a/FlipcashUITests/Smoke/CurrencySelectionSmokeTests.swift b/FlipcashUITests/Smoke/CurrencySelectionSmokeTests.swift index d3e57453..f6ba17d7 100644 --- a/FlipcashUITests/Smoke/CurrencySelectionSmokeTests.swift +++ b/FlipcashUITests/Smoke/CurrencySelectionSmokeTests.swift @@ -14,7 +14,7 @@ final class CurrencySelectionSmokeTests: BaseUITestCase { waitAndTap(app.buttons["Create a New Account"]) waitAndTap(app.buttons["Wrote the 12 Words Down Instead?"]) waitAndTap(app.buttons["Yes, I Wrote Them Down"]) - allowPhoneVerificationIfNeeded() + enterDisplayNameIfNeeded() allowPushNotificationsIfNeeded() assertMainScreenReached() diff --git a/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift b/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift index 57057982..c1893ed4 100644 --- a/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift +++ b/FlipcashUITests/Smoke/DiscoverCurrenciesSmokeTests.swift @@ -14,7 +14,7 @@ final class DiscoverCurrenciesSmokeTests: BaseUITestCase { waitAndTap(app.buttons["Create a New Account"]) waitAndTap(app.buttons["Wrote the 12 Words Down Instead?"]) waitAndTap(app.buttons["Yes, I Wrote Them Down"]) - allowPhoneVerificationIfNeeded() + enterDisplayNameIfNeeded() allowPushNotificationsIfNeeded() assertMainScreenReached() diff --git a/FlipcashUITests/Support/BaseUITestCase.swift b/FlipcashUITests/Support/BaseUITestCase.swift index 0d3e4c3d..aeb4ec8a 100644 --- a/FlipcashUITests/Support/BaseUITestCase.swift +++ b/FlipcashUITests/Support/BaseUITestCase.swift @@ -79,7 +79,7 @@ class BaseUITestCase: XCTestCase { waitAndTap(app.buttons["Create a New Account"]) waitAndTap(app.buttons["Wrote the 12 Words Down Instead?"]) waitAndTap(app.buttons["Yes, I Wrote Them Down"]) - allowPhoneVerificationIfNeeded() + enterDisplayNameIfNeeded() allowPushNotificationsIfNeeded() assertMainScreenReached() } @@ -132,6 +132,18 @@ class BaseUITestCase: XCTestCase { return amountEntry } + /// Enters a display name on the onboarding name step — the mandatory step + /// that replaced phone verification. Resilient to the screen not appearing + /// (e.g. recovering an account that already has a name). + func enterDisplayNameIfNeeded() { + // OnboardingNameScreen signature: the "Your Name" text field. + let nameField = app.textFields["Your Name"] + guard nameField.waitForExistence(timeout: 15) else { return } + nameField.tap() + nameField.typeText("Test User") + waitAndTap(app.buttons["onboarding-name-next-button"]) + } + /// Drives the phone-verification flow using the backend mock phone /// (`+15005550000`), which auto-succeeds `SendVerificationCode` and /// `CheckVerificationCode` regardless of the typed code. Resilient to