mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-14 13:40:32 +00:00
CreateProfile hardcoded create-and-activate. Adds an optional onSubmit hook so the same form can create a profile for an invitation, which must not switch the active user until the prepared chat has been reassigned. Optional rather than required, unlike the Kotlin equivalent: it touches no existing call site, which matters more here because this cannot be compiled on the machine the change was written on.
497 lines
18 KiB
Swift
497 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 {
|
|
// When set, replaces the default create-and-activate action, so the same form can be
|
|
// used to create a profile for an invitation (which must not switch the active user
|
|
// until the prepared chat has been reassigned). Errors are still shown by this view.
|
|
var onSubmit: ((_ displayName: String, _ shortDescr: String?, _ image: String?) async throws -> Void)? = nil
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@Environment(\.dismiss) var dismiss
|
|
@EnvironmentObject var theme: AppTheme
|
|
@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(!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")
|
|
.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 }
|
|
if let onSubmit = onSubmit {
|
|
let name = displayName.trimmingCharacters(in: .whitespaces)
|
|
let image = profileImage
|
|
Task {
|
|
do {
|
|
try await onSubmit(name, shortDescr, image)
|
|
} catch let error {
|
|
await MainActor.run {
|
|
showCreateProfileAlert(showAlert: { alert = $0 }, error)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
let profile = Profile(
|
|
displayName: displayName.trimmingCharacters(in: .whitespaces),
|
|
fullName: "",
|
|
shortDescr: shortDescr,
|
|
image: profileImage
|
|
)
|
|
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()
|
|
}
|
|
}
|