kotlin ui

This commit is contained in:
Evgeny @ SimpleX Chat
2026-07-14 17:00:34 +00:00
parent 86597ef48a
commit 35975dcd51
7 changed files with 155 additions and 38 deletions
@@ -1294,6 +1294,7 @@ data class User(
override val displayName: String get() = profile.displayName
override val fullName: String get() = profile.fullName
override val shortDescr: String? get() = profile.shortDescr
override val profileDescription: String? get() = profile.description
override val image: String? get() = profile.image
override val localAlias: String = ""
@@ -1366,6 +1367,9 @@ interface NamedChat {
val displayName: String
val fullName: String
val shortDescr: String?
// the full profile description (contacts/business/bot addresses); null for chats that have none.
// named distinctly from shortDescr and from PendingContactConnection.description (a connection-status string).
val profileDescription: String? get() = null
val image: String?
val localAlias: String
val chatViewName: String
@@ -1491,6 +1495,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val displayName get() = contact.displayName
override val fullName get() = contact.fullName
override val shortDescr get() = contact.profile.shortDescr
override val profileDescription get() = contact.profile.description
override val image get() = contact.image
override val localAlias: String get() = contact.localAlias
override fun anyNameContains(searchAnyCase: String): Boolean = contact.anyNameContains(searchAnyCase)
@@ -1573,6 +1578,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val displayName get() = contactRequest.displayName
override val fullName get() = contactRequest.fullName
override val shortDescr get() = contactRequest.profile.shortDescr
override val profileDescription get() = contactRequest.profile.description
override val image get() = contactRequest.image
override val localAlias get() = contactRequest.localAlias
@@ -1863,6 +1869,7 @@ data class Contact(
override val displayName get() = localAlias.ifEmpty { profile.displayName }
override val fullName get() = profile.fullName
override val shortDescr get() = profile.shortDescr
override val profileDescription get() = profile.description
override val image get() = profile.image
val contactLink: String? = profile.contactLink
override val localAlias get() = profile.localAlias
@@ -2032,6 +2039,7 @@ data class Profile(
override val displayName: String,
override val fullName: String,
override val shortDescr: String?,
val description: String? = null,
override val image: String? = null,
override val localAlias : String = "",
val contactLink: String? = null,
@@ -2042,12 +2050,14 @@ data class Profile(
val badge: BadgeProof? = null,
val contactDomain: SimplexDomainClaim? = null
): NamedChat {
override val profileDescription: String? get() = description
val profileViewName: String
get() {
return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)"
}
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain)
fun toLocalProfile(profileId: Long): LocalProfile = LocalProfile(profileId, displayName, fullName, shortDescr, description, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain)
companion object {
val sampleData = Profile(
@@ -2064,6 +2074,7 @@ data class LocalProfile(
override val displayName: String,
override val fullName: String,
override val shortDescr: String?,
val description: String? = null,
override val image: String? = null,
override val localAlias: String,
val contactLink: String? = null,
@@ -2073,9 +2084,11 @@ data class LocalProfile(
val contactDomain: SimplexDomainClaim? = null,
val contactDomainVerified: Boolean? = null
): NamedChat {
override val profileDescription: String? get() = description
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain)
fun toProfile(): Profile = Profile(displayName, fullName, shortDescr, description, image, localAlias, contactLink, preferences, peerType, contactDomain = contactDomain)
companion object {
val sampleData = LocalProfile(
@@ -2553,6 +2566,7 @@ data class GroupMember (
}
override val fullName: String get() = memberProfile.fullName
override val shortDescr: String? get() = memberProfile.shortDescr
override val profileDescription: String? get() = memberProfile.description
override val image: String? get() = memberProfile.image
val contactLink: String? = memberProfile.contactLink
val verified get() = activeConn?.connectionCode != null
@@ -4858,6 +4872,8 @@ sealed class Format {
@Serializable @SerialName("simplexName") class SimplexName(val nameInfo: SimplexNameInfo): Format()
@Serializable @SerialName("command") class Command(val commandStr: String): Format()
@Serializable @SerialName("mention") class Mention(val memberName: String): Format()
// app-only span (not sent over the wire): a tappable "modal" reference; the renderer resolves modalName to content in the current context
@Serializable @SerialName("modal") class Modal(val modalName: String): Format()
@Serializable @SerialName("email") class Email: Format()
@Serializable @SerialName("phone") class Phone: Format()
@Serializable @SerialName("unknown") class Unknown: Format()
@@ -4878,6 +4894,7 @@ sealed class Format {
is Mention -> SpanStyle(fontWeight = FontWeight.Medium)
is Email -> linkStyle
is Phone -> linkStyle
is Modal -> linkStyle
is Unknown -> SpanStyle()
}
@@ -370,7 +370,7 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, shortDesc
withBGApi {
val rhId = chatModel.remoteHostId()
val user = chatModel.controller.apiCreateActiveUser(
rhId, Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image)
rhId, Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image = image)
) ?: return@withBGApi
chatModel.currentUser.value = user
if (chatModel.users.isEmpty()) {
@@ -389,7 +389,7 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, shortDesc
fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) {
withBGApi {
chatModel.currentUser.value = chatModel.controller.apiCreateActiveUser(
null, Profile(displayName.trim(), "", null, null)
null, Profile(displayName.trim(), "", shortDescr = null)
) ?: return@withBGApi
chatModel.localUserCreated.value = true
// new users don't need the local file encryption indicator (all files are encrypted); existing users keep it on
@@ -775,19 +775,69 @@ fun ChatInfoDescription(c: NamedChat, displayName: String, copyNameToClipboard:
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF).combinedClickable(onClick = copyFullName, onLongClick = copyFullName).onRightClick(copyFullName)
)
}
val descr = c.shortDescr?.trim()
if (descr != null && descr != "") {
ProfileDescriptionText(
shortDescr = c.shortDescr,
description = c.profileDescription,
style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.onBackground, lineHeight = 21.sp, textAlign = TextAlign.Center),
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF)
)
}
// A compact profile description: shows shortDescr, or a truncated first line of the full description,
// with a tappable "Read more" that opens the full description in a modal. Used for contacts and, later,
// business/bot addresses. Groups keep showing only shortDescr (their welcome message is shown separately).
@Composable
fun ProfileDescriptionText(shortDescr: String?, description: String?, style: TextStyle, modifier: Modifier = Modifier) {
val short = shortDescr?.trim()?.ifEmpty { null }
val descr = description?.trim()?.ifEmpty { null }
val uriHandler = LocalUriHandler.current
val linkMode = chatModel.simplexLinkMode.value
if (descr == null) {
// no full description: render shortDescr as before (or nothing)
if (short != null) {
MarkdownText(
short, parseToMarkdown(short), toggleSecrets = true, style = style, maxLines = 4,
overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode
)
}
return
}
val firstLine = descr.lineSequence().first()
val truncated = firstLine.length > 100
val multiline = descr.length > firstLine.length
// when there is no shortDescr and the whole description already fits on one short line, show it inline
if (short == null && !truncated && !multiline) {
MarkdownText(
descr,
parseToMarkdown(descr),
toggleSecrets = true,
style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.onBackground, lineHeight = 21.sp, textAlign = TextAlign.Center),
maxLines = 4,
overflow = TextOverflow.Ellipsis,
uriHandler = LocalUriHandler.current,
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF),
linkMode = chatModel.simplexLinkMode.value
descr, parseToMarkdown(descr), toggleSecrets = true, style = style, maxLines = 4,
overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode
)
return
}
val teaser = short ?: (if (truncated) firstLine.take(100).trimEnd() + "" else firstLine + "")
val readMore = stringResource(MR.strings.whats_new_read_more)
val formatted = (parseToMarkdown(teaser) ?: FormattedText.plain(teaser)) +
FormattedText(" ") + FormattedText(readMore, Format.Modal("description"))
MarkdownText(
"$teaser $readMore", formatted, toggleSecrets = true, style = style, maxLines = 4,
overflow = TextOverflow.Ellipsis, uriHandler = uriHandler, modifier = modifier, linkMode = linkMode,
onModalClick = { showFullProfileDescription(descr) }
)
}
private fun showFullProfileDescription(description: String) {
ModalManager.end.showModalCloseable { _ ->
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.profile_description__field))
MarkdownText(
description,
parseToMarkdown(description),
toggleSecrets = true,
style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp),
uriHandler = LocalUriHandler.current,
linkMode = chatModel.simplexLinkMode.value,
modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING),
)
}
}
}
@@ -2328,20 +2328,12 @@ fun BoxScope.ChatItemsList(
)
}
val descr = chatInfo.shortDescr?.trim()
if (descr != null && descr != "") {
MarkdownText(
descr,
parseToMarkdown(descr),
toggleSecrets = true,
style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.onBackground, lineHeight = 21.sp, textAlign = TextAlign.Center),
maxLines = 4,
overflow = TextOverflow.Ellipsis,
uriHandler = LocalUriHandler.current,
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF),
linkMode = linkMode
)
}
ProfileDescriptionText(
shortDescr = chatInfo.shortDescr,
description = chatInfo.profileDescription,
style = MaterialTheme.typography.body2.copy(color = MaterialTheme.colors.onBackground, lineHeight = 21.sp, textAlign = TextAlign.Center),
modifier = Modifier.padding(top = DEFAULT_PADDING_HALF)
)
when (chatInfo) {
is ChatInfo.Direct -> ContactSimplexNameView(chatInfo.contact, verifiable = false)
@@ -113,6 +113,7 @@ fun MarkdownText (
linkMode: SimplexLinkMode,
inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = null,
onLinkLongClick: (link: String) -> Unit = {},
onModalClick: ((modalName: String) -> Unit)? = null,
showViaProxy: Boolean = false,
showTimestamp: Boolean = true,
prefix: AnnotatedString? = null,
@@ -193,6 +194,7 @@ fun MarkdownText (
var hasLinks = false
var hasSecrets = false
var hasCommands = false
var hasModals = false
val annotatedText = buildAnnotatedString {
inlineContent?.first?.invoke(this)
appendSender(this, sender, senderBold)
@@ -302,6 +304,17 @@ fun MarkdownText (
withStyle(ftStyle) { append(ft.text) }
}
}
is Format.Modal -> {
if (onModalClick == null) {
append(ft.text)
} else {
hasModals = true
val ftStyle = Format.linkStyle
withAnnotation(tag = "MODAL", annotation = ft.format.modalName) {
withStyle(ftStyle) { append(ft.text) }
}
}
}
is Format.Unknown -> append(ft.text)
}
}
@@ -315,7 +328,7 @@ fun MarkdownText (
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
}
val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) }
if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) {
if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null) || (hasModals && onModalClick != null)) {
val icon = remember { mutableStateOf(PointerIcon.Text) }
ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
@@ -353,11 +366,14 @@ fun MarkdownText (
if (hasCommands && sendCommandMsg != null) {
withAnnotation("COMMAND") { a -> sendCommandMsg("/${a.item}") }
}
if (hasModals && onModalClick != null) {
withAnnotation("MODAL") { a -> onModalClick(a.item) }
}
},
onHover = { offset ->
val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) }
icon.value =
if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) {
if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND") || hasAnnotation("MODAL")) {
PointerIcon.Hand
} else {
PointerIcon.Text
@@ -367,6 +383,7 @@ fun MarkdownText (
annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset)
|| annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
|| annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset)
|| annotatedText.hasStringAnnotations(tag = "MODAL", start = offset, end = offset)
},
onTextLayout = { onTextLayoutResult?.invoke(it) }
)
@@ -24,6 +24,7 @@ import chat.simplex.common.views.onboarding.ReadableText
import chat.simplex.common.platform.*
import chat.simplex.common.views.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.net.URI
@@ -40,9 +41,9 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
UserProfileLayout(
profile = profile,
close,
saveProfile = { displayName, fullName, shortDescr, image ->
saveProfile = { displayName, fullName, shortDescr, description, image ->
withBGApi {
val updatedProfile = profile.copy(displayName = displayName.trim(), fullName = fullName.trim(), shortDescr = shortDescr.trim().ifEmpty { null }, image = image)
val updatedProfile = profile.copy(displayName = displayName.trim(), fullName = fullName.trim(), shortDescr = shortDescr.trim().ifEmpty { null }, description = description.trim().ifEmpty { null }, image = image)
val updated = chatModel.controller.apiUpdateProfile(user.remoteHostId, updatedProfile)
if (updated != null) {
val (newProfile, _) = updated
@@ -60,12 +61,13 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
fun UserProfileLayout(
profile: Profile,
close: () -> Unit,
saveProfile: (String, String, String, String?) -> Unit,
saveProfile: (String, String, String, String, String?) -> Unit,
) {
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val displayName = remember { mutableStateOf(profile.displayName) }
val fullName = remember { mutableStateOf(profile.fullName) }
val shortDescr = remember { mutableStateOf(profile.shortDescr ?: "") }
val description = remember { mutableStateOf(profile.description ?: "") }
val chosenImage = rememberSaveable { mutableStateOf<URI?>(null) }
val profileImage = rememberSaveable { mutableStateOf(profile.image) }
val scope = rememberCoroutineScope()
@@ -90,12 +92,13 @@ fun UserProfileLayout(
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, profileImage.value) }, close)
showUnsavedChangesAlert({ saveProfile(displayName.value, fullName.value, shortDescr.value, description.value, profileImage.value) }, close)
}
}
ModalView(close = closeWithAlert) {
@@ -167,9 +170,20 @@ fun UserProfileLayout(
}
ProfileNameField(shortDescr)
Spacer(Modifier.height(DEFAULT_PADDING))
// opens the description editor on top of the profile editor; the edited value is saved
// together with the rest of the profile by the "Save and notify contacts" button below.
Text(
stringResource(if (description.value.isBlank()) MR.strings.add_description else MR.strings.edit_description),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable {
ModalManager.start.showModalCloseable { ProfileDescriptionEditor(description) }
}
)
Spacer(Modifier.height(DEFAULT_PADDING))
val enabled = !dataUnchanged && canSaveProfile(displayName.value, shortDescr.value, profile)
val saveModifier: Modifier = Modifier.clickable(enabled) { saveProfile(displayName.value, fullName.value, shortDescr.value, profileImage.value) }
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(
stringResource(MR.strings.save_and_notify_contacts),
@@ -218,6 +232,29 @@ fun DeleteImageButton(click: () -> Unit) {
}
}
// Editor for the full profile description, opened on top of the profile editor. It edits the shared
// `description` state in place; there is no separate save here — the profile "Save and notify contacts"
// button persists it together with the rest of the profile.
@Composable
private fun ProfileDescriptionEditor(description: MutableState<String>) {
val focusRequester = remember { FocusRequester() }
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
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 = focusRequester,
)
SectionBottomSpacer()
}
LaunchedEffect(Unit) {
delay(200)
focusRequester.requestFocus()
}
}
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.save_preferences_question),
@@ -248,7 +285,7 @@ fun PreviewUserProfileLayoutEditOff() {
UserProfileLayout(
profile = Profile.sampleData,
close = {},
saveProfile = { _, _, _, _ -> }
saveProfile = { _, _, _, _, _ -> }
)
}
}
@@ -264,7 +301,7 @@ fun PreviewUserProfileLayoutEditOn() {
UserProfileLayout(
profile = Profile.sampleData,
close = {},
saveProfile = { _, _, _, _ -> }
saveProfile = { _, _, _, _, _ -> }
)
}
}
@@ -1247,6 +1247,10 @@
<string name="full_name__field">Full name:</string>
<string name="short_descr__field">Bio:</string>
<string name="bio_too_large">Bio too large</string>
<string name="profile_description__field">Description</string>
<string name="add_description">Add description</string>
<string name="edit_description">Edit description</string>
<string name="enter_description_optional">Enter description (optional)</string>
<string name="your_current_profile">Your current profile</string>
<string name="your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it">Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile.</string>
<string name="edit_image">Edit image</string>