Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 4 additions & 24 deletions Flipcash/Core/Screens/Onboarding/IntroScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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 {
Expand Down
88 changes: 88 additions & 0 deletions Flipcash/Core/Screens/Onboarding/OnboardingNameScreen.swift
Original file line number Diff line number Diff line change
@@ -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)
}
90 changes: 90 additions & 0 deletions Flipcash/Core/Screens/Onboarding/OnboardingNameViewModel.swift
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
}
}
69 changes: 20 additions & 49 deletions Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 -

Expand All @@ -52,7 +51,7 @@ class OnboardingViewModel {

func createAccountAction() {
inflightMnemonic = MnemonicPhrase.generate(.words12)
phoneVerificationViewModel = nil
nameViewModel = nil

navigateToAccessKey()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
}

}
Expand All @@ -320,8 +292,7 @@ nonisolated enum OnboardingPath {
case login
case accessKey
case accessKeyHelp
case phoneVerification
case confirmPhoneNumberCode
case displayName
case pushNotifications
case pushNotificationsDenied
}
34 changes: 34 additions & 0 deletions FlipcashTests/OnboardingNameViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion FlipcashUITests/Regression/GiveRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading