mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-31 09:30:22 +00:00
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:
co-authored by
Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
parent
79660e1dc6
commit
d95f924b73
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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(
|
||||
|
||||
+52
-17
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user