From d95f924b737ac0ff7b2f2a1d721eab5139a5cbb4 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 18 Jul 2026 08:13:05 +0100 Subject: [PATCH] ui: improve profile descriptions (#7262) * ui: improve profile descriptions * update * fix * improve layout * fix alert * more improvements * recizable iOS field * change max lines * fixes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../Views/Chat/ChatItem/MsgContentView.swift | 20 ++-- .../ComposeMessage/NativeTextEditor.swift | 2 +- .../Shared/Views/ChatList/ChatListView.swift | 10 ++ .../Views/UserSettings/UserProfile.swift | 102 ++++++++++++------ .../common/views/helpers/TextEditor.kt | 5 +- .../views/usersettings/UserProfileView.kt | 69 +++++++++--- 6 files changed, 143 insertions(+), 65 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index abda2dbcfd..c9d8858fa1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -304,28 +304,26 @@ private struct ModalText: Identifiable { } private struct FullProfileDescriptionView: View { - @Environment(\.dismiss) private var dismiss @EnvironmentObject var theme: AppTheme let description: String @State private var showSecrets: Set = [] var body: some View { - NavigationView { - ScrollView { + List { + Text("Description") + .font(.title) + .bold() + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + + Section { let r = markdownText(description, showSecrets: showSecrets, backgroundColor: theme.colors.background) msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets) .frame(maxWidth: .infinity, alignment: .leading) .fixedSize(horizontal: false, vertical: true) - .padding() - } - .navigationTitle("Description") - .modifier(ThemedBackground()) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { dismiss() } label: { Image(systemName: "xmark") } - } } } + .modifier(ThemedBackground(grouped: true)) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index c5fd8e39d0..225e83c014 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -20,7 +20,7 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var placeholder: String? @Binding var selectedRange: NSRange let onImagesAdded: ([UploadContent]) -> Void - + static let minHeight: CGFloat = 39 func makeUIView(context: Context) -> CustomUITextField { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index bcef5cecf3..238ea89e90 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -66,6 +66,7 @@ enum ActiveFilter: Identifiable, Equatable { class SaveableSettings: ObservableObject { @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [], serverWarnings: []) + var profileSave: (() -> Void)? = nil } struct ServerSettings { @@ -135,6 +136,15 @@ struct UserPickerSheetView: View { cancelButton: true ) } + if let saveProfile = ss.profileSave { + showAlert( + title: NSLocalizedString("Save your profile?", comment: "alert title"), + message: NSLocalizedString("Your profile was changed. If you save it, the updated profile will be sent to all your contacts.", comment: "alert message"), + buttonTitle: NSLocalizedString("Save (and notify contacts)", comment: "alert button"), + buttonAction: saveProfile, + cancelButton: true + ) + } } .environmentObject(ss) } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 0ad9f9003e..a2a62b557c 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -12,12 +12,13 @@ import SimpleXChat struct UserProfile: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme + @EnvironmentObject var ss: SaveableSettings @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner @State private var profile = Profile(displayName: "", fullName: "") @State private var currentProfileHash: Int? + @State private var loaded = false @State private var shortDescr = "" @State private var description = "" - @State private var editingDescription = false // Modals @State private var showChooseSource = false @State private var showImagePicker = false @@ -56,8 +57,10 @@ struct UserProfile: View { } } } - Button { - editingDescription = true + NavigationLink { + ProfileDescriptionEditor(description: $description) + .navigationTitle("Description") + .modifier(ThemedBackground(grouped: true)) } label: { Text(description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Add description" : "Edit description") } @@ -82,19 +85,13 @@ struct UserProfile: View { } // Lifecycle .onAppear { - getCurrentProfile() - } - .onDisappear { - if canSaveProfile { - showAlert( - title: NSLocalizedString("Save your profile?", comment: "alert title"), - message: NSLocalizedString("Your profile was changed. If you save it, the updated profile will be sent to all your contacts.", comment: "alert message"), - buttonTitle: NSLocalizedString("Save (and notify contacts)", comment: "alert button"), - buttonAction: saveProfile, - cancelButton: true - ) + // load once — returning from the description editor re-fires onAppear and would discard edits + if !loaded { + getCurrentProfile() + loaded = true } } + .onChange(of: editSnapshot) { _ in updateProfileSaver() } .onChange(of: chosenImage) { image in Task { let resized: String? = if let image { @@ -133,9 +130,6 @@ struct UserProfile: View { } } .alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) } - .sheet(isPresented: $editingDescription) { - ProfileDescriptionEditor(description: $description) - } } private func showFullName(_ user: User) -> Bool { @@ -169,6 +163,8 @@ struct UserProfile: View { await MainActor.run { chatModel.updateCurrentUser(newProfile) getCurrentProfile() + // onChange(editSnapshot) won't fire when saved values equal typed, so clear the pending dismiss-save here + ss.profileSave = nil } } else { alert = .duplicateUserError @@ -187,6 +183,33 @@ struct UserProfile: View { description = profile.description ?? "" } } + + private var editSnapshot: [String] { + [profile.displayName, profile.fullName, profile.image ?? "", shortDescr, description] + } + + private func updateProfileSaver() { + guard loaded, canSaveProfile else { + ss.profileSave = nil + return + } + var edited = profile + edited.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) + edited.shortDescr = shortDescr.trimmingCharacters(in: .whitespaces) + let d = description.trimmingCharacters(in: .whitespacesAndNewlines) + edited.description = d.isEmpty ? nil : d + ss.profileSave = { + Task { + do { + if let (newProfile, _) = try await apiUpdateProfile(profile: edited) { + await MainActor.run { ChatModel.shared.updateCurrentUser(newProfile) } + } + } catch { + logger.error("UserProfile save on dismiss error: \(responseError(error))") + } + } + } + } } struct EditProfileImage: View { @@ -252,29 +275,40 @@ func editImageButton(action: @escaping () -> Void) -> some View { } struct ProfileDescriptionEditor: View { - @Environment(\.dismiss) var dismiss @EnvironmentObject var theme: AppTheme @Binding var description: String + @FocusState private var keyboardVisible: Bool var body: some View { - NavigationView { - ZStack(alignment: .topLeading) { - TextEditor(text: $description) - if description.isEmpty { - Text("Enter description (optional)") - .foregroundColor(theme.colors.secondary) - .padding(.top, 8) - .padding(.leading, 5) - .allowsHitTesting(false) + List { + Section { + if #available(iOS 16.0, *) { + TextField("Enter description (optional)", text: $description, axis: .vertical) + .lineLimit(6...12) + .focused($keyboardVisible) + } else { + // iOS 15 has no vertically-growing TextField (axis:) — fixed-height editor instead + ZStack { + Group { + if description.isEmpty { + TextEditor(text: Binding.constant(NSLocalizedString("Enter description (optional)", comment: "placeholder"))) + .foregroundColor(theme.colors.secondary) + .disabled(true) + } + TextEditor(text: $description) + .focused($keyboardVisible) + } + .padding(.horizontal, -5) + .padding(.top, -8) + .frame(height: 130, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .leading) + } } } - .padding() - .navigationTitle("Description") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { dismiss() } - } + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + keyboardVisible = true } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index cad0d247e9..8ae27ca17f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -34,7 +34,8 @@ fun TextEditor( shape: Shape = RoundedCornerShape(14.dp), isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null, - enabled: Boolean = true + enabled: Boolean = true, + maxLines: Int = 5 ) { var valid by rememberSaveable { mutableStateOf(true) } var focused by rememberSaveable { mutableStateOf(false) } @@ -73,7 +74,7 @@ fun TextEditor( autoCorrect = false ), singleLine = false, - maxLines = 5, + maxLines = maxLines, cursorBrush = SolidColor(MaterialTheme.colors.secondary), decorationBox = @Composable { innerTextField -> TextFieldDefaults.TextFieldDecorationBox( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index f8bca55ab7..f2a4cc7dac 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -90,17 +90,35 @@ fun UserProfileLayout( sheetState = bottomSheetModalState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { - val dataUnchanged = + fun dataUnchanged(): Boolean = displayName.value.trim() == profile.displayName && fullName.value.trim() == profile.fullName && shortDescr.value.trim() == (profile.shortDescr ?: "") && description.value.trim() == (profile.description ?: "") && profile.image == profileImage.value - val closeWithAlert = { - if (dataUnchanged || !canSaveProfile(displayName.value, shortDescr.value, profile)) { - close() - } else { - showUnsavedChangesAlert({ saveProfile(displayName.value, fullName.value, shortDescr.value, description.value, profileImage.value) }, close) + fun onClose(close: () -> Unit): Boolean = if (dataUnchanged() || !canSaveProfile(displayName.value, shortDescr.value, profile)) { + chatModel.centerPanelBackgroundClickHandler = null + close() + false + } else { + showUnsavedChangesAlert( + { + chatModel.centerPanelBackgroundClickHandler = null + saveProfile(displayName.value, fullName.value, shortDescr.value, description.value, profileImage.value) + }, + { + chatModel.centerPanelBackgroundClickHandler = null + close() + } + ) + true + } + DisposableEffect(Unit) { + onDispose { chatModel.centerPanelBackgroundClickHandler = null } + } + LaunchedEffect(Unit) { + chatModel.centerPanelBackgroundClickHandler = { + onClose(close = { ModalManager.start.closeModals() }) } } LaunchedEffect(editingDescription) { @@ -109,18 +127,35 @@ fun UserProfileLayout( descrFocusRequester.requestFocus() } } - ModalView(close = if (editingDescription) ({ editingDescription = false }) else closeWithAlert) { + ModalView(close = if (editingDescription) ({ editingDescription = false }) else ({ onClose(close) })) { if (editingDescription) { - ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) { + // app bar is top (default) or bottom (one-handed) — mirror ColumnWithScrollBar's spacers + // so the entry area never runs under the app bar, keyboard, or system bars + val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } + Column(Modifier.fillMaxSize().imePadding().padding(horizontal = DEFAULT_PADDING)) { + if (oneHandUI.value) { + Spacer(Modifier.padding(top = DEFAULT_PADDING + 5.dp).windowInsetsTopHeight(WindowInsets.statusBars)) + } else { + Spacer(Modifier.statusBarsPadding().padding(top = AppBarHeight * fontSizeSqrtMultiplier)) + } AppBarTitle(stringResource(MR.strings.profile_description__field), withPadding = false) - TextEditor( - description, - Modifier.heightIn(min = 100.dp), - placeholder = stringResource(MR.strings.enter_description_optional), - contentPadding = PaddingValues(), - focusRequester = descrFocusRequester, - ) - SectionBottomSpacer() + // weight goes on the Box (a direct Column child); TextEditor forwards its modifier + // to the inner BasicTextField, where weight would be ignored + Box(Modifier.weight(1f, fill = false).padding(bottom = DEFAULT_PADDING)) { + TextEditor( + description, + Modifier.heightIn(min = 140.dp), + placeholder = stringResource(MR.strings.enter_description_optional), + contentPadding = PaddingValues(), + focusRequester = descrFocusRequester, + maxLines = Int.MAX_VALUE + ) + } + if (oneHandUI.value) { + Spacer(Modifier.navigationBarsPadding().padding(bottom = AppBarHeight * fontSizeSqrtMultiplier)) + } else { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + } } return@ModalView } @@ -200,7 +235,7 @@ fun UserProfileLayout( ) Spacer(Modifier.height(DEFAULT_PADDING)) - val enabled = !dataUnchanged && canSaveProfile(displayName.value, shortDescr.value, profile) + val enabled = !dataUnchanged() && canSaveProfile(displayName.value, shortDescr.value, profile) val saveModifier: Modifier = Modifier.clickable(enabled) { saveProfile(displayName.value, fullName.value, shortDescr.value, description.value, profileImage.value) } val saveColor: Color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Text(