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>
This commit is contained in:
Evgeny
2026-07-18 08:13:05 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
parent 79660e1dc6
commit d95f924b73
6 changed files with 143 additions and 65 deletions
@@ -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<Int> = []
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))
}
}
@@ -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 {
@@ -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)
}
@@ -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
}
}
}
@@ -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(
@@ -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(