Files
simplex-chat/apps/ios/Shared/Views/Onboarding/CreateProfile.swift
T
Narasimha-sc d97fe5fd0c ios: offer creating a profile for an invitation
Mirrors the Kotlin flow: both pickers get an "Add profile" row, and
CreateProfile gains an optional onCreate so the same form is reused. Each
picker's create function is five lines that end in the path a row tap
already takes - changeProfile(newUser) for a prepared chat,
selectedProfile plus .switchingUser for a one-time link.

The sheets are attached to body rather than to a row, because rows live
in a LazyVStack and profilePicker() is swapped for currentSelection()
when listExpanded flips. Surface 1's picker height is computed from the
row count, so it needs one more row.

The list is refreshed after creating, for the same reason as Kotlin, and
interactive dismissal is blocked while the create is in flight so backing
out cannot leave the invitation moved.

Not compiled: no Swift toolchain available.
2026-09-05 11:27:56 +00:00

494 lines
18 KiB
Swift

//
// CreateProfile.swift
// SimpleX (iOS)
//
// Created by Evgeny on 07/05/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
// Spec: spec/client/navigation.md
import SwiftUI
import SimpleXChat
enum UserProfileAlert: Identifiable {
case duplicateUserError
case invalidDisplayNameError
case createUserError(error: LocalizedStringKey)
case invalidNameError(validName: String)
var id: String {
switch self {
case .duplicateUserError: return "duplicateUserError"
case .invalidDisplayNameError: return "invalidDisplayNameError"
case .createUserError: return "createUserError"
case let .invalidNameError(validName): return "invalidNameError \(validName)"
}
}
}
let MAX_BIO_LENGTH_BYTES = 160
struct CreateProfile: View {
var onCreate: ((Profile) async throws -> Void)? = nil
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) var dismiss
@EnvironmentObject var theme: AppTheme
@State private var submitting = false
@State private var displayName: String = ""
@State private var profileBio: String = ""
@FocusState private var focusDisplayName
@State private var alert: UserProfileAlert?
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil
@State private var profileImage: String? = nil
var body: some View {
List {
Group {
HStack(spacing: 0) {
Spacer(minLength: 0)
ZStack(alignment: .center) {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: profileImage, size: 128)
if profileImage != nil {
Button {
profileImage = nil
} label: {
Image(systemName: "multiply")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 12)
}
}
}
editImageButton { showChooseSource = true }
.buttonStyle(BorderlessButtonStyle())
}
.padding(.horizontal, 10) // Offsets transparent space built into 3D asset
Spacer(minLength: 0)
#if SIMPLEX_ASSETS
Image(colorScheme == .light ? "create-profile" : "create-profile-light")
.resizable()
.scaledToFit()
.frame(height: 140)
// No trailing spacer — asset image has empty space on the right
#endif
}
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0))
Section {
ZStack(alignment: .leading) {
let name = displayName.trimmingCharacters(in: .whitespaces)
if name != mkValidName(name) {
Button {
alert = .invalidNameError(validName: mkValidName(name))
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
}
TextField("Enter your name…", text: $displayName)
.padding(.leading, 36)
.focused($focusDisplayName)
}
ZStack(alignment: .leading) {
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
TextField("Bio", text: $profileBio)
.padding(.leading, 36)
}
Button(action: createProfile) {
settingsRow("checkmark", color: theme.colors.primary) { Text("Create profile") }
}
.disabled(submitting || !canCreateProfile(displayName) || !bioFitsLimit())
} footer: {
VStack(alignment: .leading, spacing: 8) {
Text("Your profile is stored on your device and only shared with your contacts.")
}
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.compactSectionSpacing()
}
.navigationTitle("Create your profile")
.interactiveDismissDisabled(submitting)
.modifier(ThemedBackground(grouped: true))
.alert(item: $alert) { a in userProfileAlert(a, $displayName) }
.confirmationDialog("Profile image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
showTakePhoto = true
}
Button("Choose from library") {
showImagePicker = true
}
}
.fullScreenCover(isPresented: $showTakePhoto) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
CameraImagePicker(image: $chosenImage)
}
}
.sheet(isPresented: $showImagePicker) {
LibraryImagePicker(image: $chosenImage) { _ in
await MainActor.run {
showImagePicker = false
}
}
}
.onChange(of: chosenImage) { image in
Task {
let resized: String? = if let image {
await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
} else {
nil
}
await MainActor.run { profileImage = resized }
}
}
.onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
focusDisplayName = true
}
}
}
private func bioFitsLimit() -> Bool {
chatJsonLength(profileBio) <= MAX_BIO_LENGTH_BYTES
}
private func createProfile() {
hideKeyboard()
let shortDescr: String? = if profileBio.isEmpty { nil } else { profileBio }
let profile = Profile(
displayName: displayName.trimmingCharacters(in: .whitespaces),
fullName: "",
shortDescr: shortDescr,
image: profileImage
)
if let onCreate {
submitting = true
Task {
do {
try await onCreate(profile)
} catch let error {
await MainActor.run { showCreateProfileAlert(showAlert: { alert = $0 }, error) }
}
await MainActor.run { submitting = false }
}
return
}
let m = ChatModel.shared
do {
AppChatState.shared.set(.active)
m.currentUser = try apiCreateActiveUser(profile)
// .isEmpty check is redundant here, but it makes it clearer what is going on
if m.users.isEmpty || m.users.allSatisfy({ $0.user.hidden }) {
try startChat()
withAnimation {
onboardingStageDefault.set(.step3_ChooseServerOperators)
m.onboardingStage = .step3_ChooseServerOperators
}
} else {
onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
dismiss()
m.users = try listUsers()
try getUserChatData()
}
} catch let error {
showCreateProfileAlert(showAlert: { alert = $0 }, error)
}
}
}
struct CreateFirstProfile: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.colorScheme) var colorScheme: ColorScheme
@State private var displayName: String = ""
@FocusState private var focusDisplayName
@State private var nextStepNavLinkActive = false
@State private var showMigrateSheet = false
var body: some View {
let spacing: CGFloat = 10
let topPadding: CGFloat = 8
let padding: CGFloat = 25
GeometryReader { g in
let v = ScrollView {
VStack(alignment: .center, spacing: spacing) {
#if SIMPLEX_ASSETS
Image(colorScheme == .light ? "your-profile" : "your-profile-light")
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
#else
ZStack {
let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5)
LinearGradient(
stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops,
startPoint: gp.start,
endPoint: gp.end
)
Image(systemName: "person.crop.rectangle")
.font(.system(size: 72))
.foregroundColor(theme.colors.primary)
}
.aspectRatio(1.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 24))
.padding(.horizontal, 25)
.frame(maxWidth: .infinity)
#endif
Text("Your profile")
.font(.largeTitle)
.bold()
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text("On your phone, not on servers.")
.font(.title3)
.fontWeight(.medium)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text("No account. No phone. No email. No ID.\nThe most secure encryption.")
.font(.footnote)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
profileNameField()
.padding(.top)
.padding(.bottom, 5)
Spacer(minLength: 0)
createProfileButton()
.padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0)
}
.padding(.horizontal, padding)
.padding(.top, topPadding)
.padding(.bottom, padding)
.frame(minHeight: g.size.height)
}
.onTapGesture { focusDisplayName = false }
.sheet(isPresented: $showMigrateSheet, onDismiss: { m.migrationState = nil }) {
NavigationView {
MigrateToDevice(migrationState: $m.migrationState)
.navigationTitle("Migrate here")
.modifier(ThemedBackground(grouped: true))
}
}
if #available(iOS 17, *) {
v.scrollBounceBehavior(.basedOnSize).defaultScrollAnchor(.bottom)
} else if #available(iOS 16.4, *) {
v.scrollBounceBehavior(.basedOnSize)
} else {
v
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
if m.migrationState == nil {
m.migrationState = .pasteOrScanLink
}
showMigrateSheet = true
} label: {
HStack(spacing: 4) {
Image(systemName: "tray.and.arrow.down")
Text("Migrate")
.fontWeight(.medium)
}
}
}
}
.onAppear() {
if #available(iOS 16, *) {
focusDisplayName = true
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
focusDisplayName = true
}
}
}
.frame(maxHeight: .infinity)
}
private func profileNameField() -> some View {
let name = displayName.trimmingCharacters(in: .whitespaces)
let validName = mkValidName(name)
return ZStack(alignment: .trailing) {
TextField("Enter profile name...", text: $displayName)
.focused($focusDisplayName)
.padding(.horizontal)
.padding(.trailing, name != validName ? 20 : 0)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(uiColor: .tertiarySystemFill))
)
if name != validName {
Button {
showAlert(.invalidNameError(validName: validName))
} label: {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
.padding(.horizontal, 10)
}
}
}
}
private func createProfileButton() -> some View {
ZStack {
Button {
createProfile()
} label: {
Text("Create profile")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: !canCreateProfile(displayName)))
.disabled(!canCreateProfile(displayName))
NavigationLink(isActive: $nextStepNavLinkActive) {
nextStepDestinationView()
} label: {
EmptyView()
}
.frame(width: 1, height: 1)
.hidden()
}
}
private func showAlert(_ alert: UserProfileAlert) {
AlertManager.shared.showAlert(userProfileAlert(alert, $displayName))
}
private func nextStepDestinationView() -> some View {
YourNetworkView()
.navigationBarBackButtonHidden(true)
.modifier(ThemedBackground())
}
private func createProfile() {
hideKeyboard()
let profile = Profile(
displayName: displayName.trimmingCharacters(in: .whitespaces),
fullName: ""
)
let m = ChatModel.shared
do {
AppChatState.shared.set(.active)
m.currentUser = try apiCreateActiveUser(profile)
// new users don't need the local file encryption indicator (all files are encrypted); existing users keep it on
UserDefaults.standard.set(false, forKey: DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION)
try startChat(onboarding: true)
onboardingStageDefault.set(.step3_ChooseServerOperators)
nextStepNavLinkActive = true
} catch let error {
showCreateProfileAlert(showAlert: showAlert, error)
}
}
}
private func showCreateProfileAlert(
showAlert: (UserProfileAlert) -> Void,
_ error: Error
) {
let m = ChatModel.shared
switch error as? ChatError {
case .errorStore(.duplicateName),
.error(.userExists):
if m.currentUser == nil {
AlertManager.shared.showAlert(duplicateUserAlert)
} else {
showAlert(.duplicateUserError)
}
case .error(.invalidDisplayName):
if m.currentUser == nil {
AlertManager.shared.showAlert(invalidDisplayNameAlert)
} else {
showAlert(.invalidDisplayNameError)
}
default:
let err: LocalizedStringKey = "Error: \(responseError(error))"
if m.currentUser == nil {
AlertManager.shared.showAlert(creatUserErrorAlert(err))
} else {
showAlert(.createUserError(error: err))
}
}
logger.error("Failed to create user or start chat: \(responseError(error))")
}
private func canCreateProfile(_ displayName: String) -> Bool {
let name = displayName.trimmingCharacters(in: .whitespaces)
return name != "" && mkValidName(name) == name
}
func userProfileAlert(_ alert: UserProfileAlert, _ displayName: Binding<String>) -> Alert {
switch alert {
case .duplicateUserError: return duplicateUserAlert
case .invalidDisplayNameError: return invalidDisplayNameAlert
case let .createUserError(err): return creatUserErrorAlert(err)
case let .invalidNameError(name): return createInvalidNameAlert(name, displayName)
}
}
private var duplicateUserAlert: Alert {
Alert(
title: Text("Duplicate display name!"),
message: Text("You already have a chat profile with the same display name. Please choose another name.")
)
}
private var invalidDisplayNameAlert: Alert {
Alert(
title: Text("Invalid display name!"),
message: Text("This display name is invalid. Please choose another name.")
)
}
private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert {
Alert(
title: Text("Error creating profile!"),
message: Text(err)
)
}
func createInvalidNameAlert(_ name: String, _ displayName: Binding<String>) -> Alert {
name == ""
? Alert(title: Text("Invalid name!"))
: Alert(
title: Text("Invalid name!"),
message: Text("Correct name to \(name)?"),
primaryButton: .default(
Text("Ok"),
action: { displayName.wrappedValue = name }
),
secondaryButton: .cancel()
)
}
func validDisplayName(_ name: String) -> Bool {
mkValidName(name.trimmingCharacters(in: .whitespaces)) == name
}
func mkValidName(_ s: String) -> String {
var c = s.cString(using: .utf8)!
return fromCString(chat_valid_name(&c)!)
}
struct CreateProfile_Previews: PreviewProvider {
static var previews: some View {
CreateProfile()
}
}