From e87c78e997058fce6643f150bcfd5d859def3a7e Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Fri, 29 Jul 2022 20:11:00 +0400 Subject: [PATCH] android: groups ui (#850) --- .../java/chat/simplex/app/model/ChatModel.kt | 80 ++++- .../java/chat/simplex/app/model/SimpleXAPI.kt | 37 ++- .../simplex/app/views/chat/ChatInfoView.kt | 279 +++++++++------- .../chat/simplex/app/views/chat/ChatView.kt | 120 +++++-- .../views/chat/group/AddGroupMembersView.kt | 300 ++++++++++++++++++ .../app/views/chat/group/GroupChatInfoView.kt | 291 +++++++++++++++++ .../views/chat/group/GroupMemberInfoView.kt | 181 +++++++++++ .../app/views/chatlist/ChatListNavLinkView.kt | 75 +++-- .../app/views/chatlist/ChatListView.kt | 5 +- .../app/views/database/ChatArchiveView.kt | 13 +- .../app/views/database/DatabaseView.kt | 28 +- .../app/views/helpers/ChatInfoImage.kt | 4 +- .../chat/simplex/app/views/helpers/Section.kt | 82 +++++ .../simplex/app/views/helpers/SimpleButton.kt | 7 +- .../app/views/usersettings/CallSettings.kt | 13 +- .../usersettings/ExperimentalFeaturesView.kt | 3 +- .../app/views/usersettings/NetworkSettings.kt | 3 +- .../app/views/usersettings/PrivacySettings.kt | 12 +- .../app/views/usersettings/SettingsView.kt | 105 +++--- .../app/src/main/res/values-ru/strings.xml | 68 +++- .../app/src/main/res/values/strings.xml | 68 +++- 21 files changed, 1490 insertions(+), 284 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index bbc4e021b5..266f739d9e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -26,8 +26,11 @@ class ChatModel(val controller: ChatController) { val chatRunning = mutableStateOf(null) val chatDbChanged = mutableStateOf(false) val chats = mutableStateListOf() + + // current chat val chatId = mutableStateOf(null) val chatItems = mutableStateListOf() + val groupMembers = mutableStateListOf() var connReqInvitation: String? = null val terminalItems = mutableStateListOf() @@ -315,7 +318,12 @@ data class Chat ( @Serializable sealed class NetworkStatus { - val statusString: String get() = if (this is Connected) generalGetString(R.string.server_connected) else generalGetString(R.string.server_connecting) + val statusString: String get() = + when (this) { + is Connected -> generalGetString(R.string.server_connected) + is Error -> generalGetString(R.string.server_error) + else -> generalGetString(R.string.server_connecting) + } val statusExplanation: String get() = when (this) { is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact) @@ -523,6 +531,9 @@ class GroupInfo ( || (s == GroupMemberStatus.MemRemoved || s == GroupMemberStatus.MemLeft || s == GroupMemberStatus.MemGroupDeleted || s == GroupMemberStatus.MemInvited) } + val canAddMembers: Boolean + get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive + companion object { val sampleData = GroupInfo( groupId = 1, @@ -563,6 +574,14 @@ class GroupMember ( val memberContactId: Long? = null, var activeConn: Connection? = null ) { + val id: String get() = "#$groupId @$groupMemberId" + val displayName: String get() = memberProfile.displayName + val fullName: String get() = memberProfile.fullName + val image: String? get() = memberProfile.image + + val chatViewName: String + get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName") + val memberActive: Boolean get() = when (this.memberStatus) { GroupMemberStatus.MemRemoved -> false GroupMemberStatus.MemLeft -> false @@ -577,6 +596,23 @@ class GroupMember ( GroupMemberStatus.MemCreator -> true } + val memberCurrent: Boolean get() = when (this.memberStatus) { + GroupMemberStatus.MemRemoved -> false + GroupMemberStatus.MemLeft -> false + GroupMemberStatus.MemGroupDeleted -> false + GroupMemberStatus.MemInvited -> false + GroupMemberStatus.MemIntroduced -> true + GroupMemberStatus.MemIntroInvited -> true + GroupMemberStatus.MemAccepted -> true + GroupMemberStatus.MemAnnounced -> true + GroupMemberStatus.MemConnected -> true + GroupMemberStatus.MemComplete -> true + GroupMemberStatus.MemCreator -> true + } + + fun canRemove(userRole: GroupMemberRole): Boolean = + userRole >= GroupMemberRole.Admin && userRole >= memberRole + companion object { val sampleData = GroupMember( groupMemberId = 1, @@ -595,10 +631,16 @@ class GroupMember ( } @Serializable -enum class GroupMemberRole { - @SerialName("member") Member, - @SerialName("admin") Admin, - @SerialName("owner") Owner; +enum class GroupMemberRole(val memberRole: String) { + @SerialName("member") Member("member"), // order matters in comparisons + @SerialName("admin") Admin("admin"), + @SerialName("owner") Owner("owner"); + + val text: String get() = when (this) { + Member -> generalGetString(R.string.group_member_role_member) + Admin -> generalGetString(R.string.group_member_role_admin) + Owner -> generalGetString(R.string.group_member_role_owner) + } } @Serializable @@ -623,6 +665,34 @@ enum class GroupMemberStatus { @SerialName("connected") MemConnected, @SerialName("complete") MemComplete, @SerialName("creator") MemCreator; + + val text: String get() = when (this) { + MemRemoved -> generalGetString(R.string.group_member_status_removed) + MemLeft -> generalGetString(R.string.group_member_status_left) + MemGroupDeleted -> generalGetString(R.string.group_member_status_group_deleted) + MemInvited -> generalGetString(R.string.group_member_status_invited) + MemIntroduced -> generalGetString(R.string.group_member_status_introduced) + MemIntroInvited -> generalGetString(R.string.group_member_status_intro_invitation) + MemAccepted -> generalGetString(R.string.group_member_status_accepted) + MemAnnounced -> generalGetString(R.string.group_member_status_announced) + MemConnected -> generalGetString(R.string.group_member_status_connected) + MemComplete -> generalGetString(R.string.group_member_status_complete) + MemCreator -> generalGetString(R.string.group_member_status_creator) + } + + val shortText: String get() = when (this) { + MemRemoved -> generalGetString(R.string.group_member_status_removed) + MemLeft -> generalGetString(R.string.group_member_status_left) + MemGroupDeleted -> generalGetString(R.string.group_member_status_group_deleted) + MemInvited -> generalGetString(R.string.group_member_status_invited) + MemIntroduced -> generalGetString(R.string.group_member_status_connecting) + MemIntroInvited -> generalGetString(R.string.group_member_status_connecting) + MemAccepted -> generalGetString(R.string.group_member_status_connecting) + MemAnnounced -> generalGetString(R.string.group_member_status_connecting) + MemConnected -> generalGetString(R.string.group_member_status_connected) + MemComplete -> generalGetString(R.string.group_member_status_complete) + MemCreator -> generalGetString(R.string.group_member_status_creator) + } } @Serializable diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index e100807b0a..a577882a3b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -445,6 +445,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager return null } + suspend fun apiListContacts(): List? { + val r = sendCmd(CC.ListContacts()) + if (r is CR.ContactsList) return r.contacts + Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiUpdateProfile(profile: Profile): Profile? { val r = sendCmd(CC.ApiUpdateProfile(profile)) if (r is CR.UserProfileNoChange) return profile @@ -552,6 +559,12 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager return null } + suspend fun apiAddMember(groupId: Long, contactId: Long, memberRole: GroupMemberRole) { + val r = sendCmd(CC.ApiAddMember(groupId, contactId, memberRole)) + if (r is CR.SentGroupInvitation) return + Log.e(TAG, "apiAddMember bad response: ${r.responseType} ${r.details}") + } + suspend fun apiJoinGroup(groupId: Long): GroupInfo? { val r = sendCmd(CC.ApiJoinGroup(groupId)) if (r is CR.UserAcceptedGroupSent) return r.groupInfo @@ -559,6 +572,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager return null } + suspend fun apiRemoveMember(groupId: Long, memberId: Long): GroupMember? { + val r = sendCmd(CC.ApiRemoveMember(groupId, memberId)) + if (r is CR.UserDeletedMember) return r.member + Log.e(TAG, "apiRemoveMember bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiLeaveGroup(groupId: Long): GroupInfo? { val r = sendCmd(CC.ApiLeaveGroup(groupId)) if (r is CR.LeftMemberUser) return r.groupInfo @@ -566,6 +586,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager return null } + suspend fun apiListMembers(groupId: Long): List { + val r = sendCmd(CC.ApiListMembers(groupId)) + if (r is CR.GroupMembers) return r.group.members + Log.e(TAG, "apiListMembers bad response: ${r.responseType} ${r.details}") + return emptyList() + } + fun apiErrorAlert(method: String, title: String, r: CR) { val errMsg = "${r.responseType}: ${r.details}" Log.e(TAG, "$method bad response: $errMsg") @@ -625,7 +652,7 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE && appPrefs.privacyAcceptImages.get()) { withApi { receiveFile(file.fileId) } } - if (!cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) { + if (!cItem.chatDir.sent && !cItem.isCall && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(cInfo, cItem) } } @@ -987,6 +1014,7 @@ sealed class CC { class Connect(val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() + class ListContacts: CC() class ApiUpdateProfile(val profile: Profile): CC() class ApiParseMarkdown(val text: String): CC() class CreateMyAddress: CC() @@ -1020,7 +1048,7 @@ sealed class CC { is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is NewGroup -> "/group ${groupProfile.displayName} ${groupProfile.fullName}" - is ApiAddMember -> "/_add #$groupId $contactId $memberRole" + is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiRemoveMember -> "/_remove #$groupId $memberId" is ApiLeaveGroup -> "/_leave #$groupId" @@ -1035,6 +1063,7 @@ sealed class CC { is Connect -> "/connect $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" + is ListContacts -> "/contacts" is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}" is ApiParseMarkdown -> "/_parse $text" is CreateMyAddress -> "/address" @@ -1084,6 +1113,7 @@ sealed class CC { is Connect -> "connect" is ApiDeleteChat -> "apiDeleteChat" is ApiClearChat -> "apiClearChat" + is ListContacts -> "listContacts" is ApiUpdateProfile -> "updateProfile" is ApiParseMarkdown -> "apiParseMarkdown" is CreateMyAddress -> "createMyAddress" @@ -1190,6 +1220,7 @@ sealed class CR { @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR() + @Serializable @SerialName("contactsList") class ContactsList(val contacts: List): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact): CR() @@ -1274,6 +1305,7 @@ sealed class CR { is ChatItemStatusUpdated -> "chatItemStatusUpdated" is ChatItemUpdated -> "chatItemUpdated" is ChatItemDeleted -> "chatItemDeleted" + is ContactsList -> "contactsList" is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" @@ -1356,6 +1388,7 @@ sealed class CR { is ChatItemStatusUpdated -> json.encodeToString(chatItem) is ChatItemUpdated -> json.encodeToString(chatItem) is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}" + is ContactsList -> json.encodeToString(contacts) is GroupCreated -> json.encodeToString(groupInfo) is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact" is UserAcceptedGroupSent -> json.encodeToString(groupInfo) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index 9f344b2677..49aa3fe3bb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -1,7 +1,12 @@ package chat.simplex.app.views.chat +import InfoRow +import SectionDivider +import SectionItemView +import SectionSpacer +import SectionView import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -13,7 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import chat.simplex.app.R @@ -29,17 +34,16 @@ fun ChatInfoView(chatModel: ChatModel, connStats: ConnectionStats?, close: () -> ChatInfoLayout( chat, connStats, - close = close, - deleteContact = { deleteChatDialog(chat.chatInfo, chatModel, close) }, + deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) }, clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) } ) } } -fun deleteChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertMsg( - title = generalGetString(R.string.delete_chat_question), - text = generalGetString(R.string.delete_chat_all_messages_deleted_cannot_undo_warning), + title = generalGetString(R.string.delete_contact_question), + text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning), confirmText = generalGetString(R.string.delete_verb), onConfirm = { withApi { @@ -73,135 +77,184 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit ) } -// TODO move to GroupChatInfoView -fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel) { - AlertManager.shared.showAlertMsg( - title = generalGetString(R.string.leave_group_question), - text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved), - confirmText = generalGetString(R.string.leave_group_button), - onConfirm = { - withApi { chatModel.controller.leaveGroup(groupInfo.groupId) } - } - ) -} - @Composable fun ChatInfoLayout( chat: Chat, connStats: ConnectionStats?, - close: () -> Unit, deleteContact: () -> Unit, clearChat: () -> Unit ) { Column( Modifier - .fillMaxSize() - .background(MaterialTheme.colors.background) - .padding(horizontal = 8.dp), + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + ChatInfoHeader(chat.chatInfo) + } + SectionSpacer() + + if (connStats != null) { + SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) { + SectionItemView { + NetworkStatusRow(chat.serverInfo.networkStatus) + } + + val rcvServers = connStats.rcvServers + if (rcvServers != null && rcvServers.isNotEmpty()) { + SectionDivider() + SimplexServers(stringResource(R.string.receiving_via), rcvServers) + } + val sndServers = connStats.sndServers + if (sndServers != null && sndServers.isNotEmpty()) { + SectionDivider() + SimplexServers(stringResource(R.string.sending_via), sndServers) + } + } + SectionSpacer() + } + + SectionView { + SectionItemView { + ClearChatButton(clearChat) + } + SectionDivider() + SectionItemView { + DeleteContactButton(deleteContact) + } + } + SectionSpacer() + + SectionView(title = stringResource(R.string.section_title_for_console)) { + InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName) + SectionDivider() + InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString()) + } + SectionSpacer() + } +} + +@Composable +fun ChatInfoHeader(cInfo: ChatInfo) { + Column( + Modifier.padding(horizontal = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - CloseSheetBar(close) - Spacer(Modifier.size(48.dp)) - val cInfo = chat.chatInfo - ChatInfoImage(cInfo, size = 192.dp) + ChatInfoImage(cInfo, size = 192.dp, iconColor = HighOrLowlight) Text( cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), color = MaterialTheme.colors.onBackground, - modifier = Modifier - .padding(top = 32.dp) - .padding(bottom = 8.dp) + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - Text( - cInfo.fullName, style = MaterialTheme.typography.h2, - color = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(bottom = 16.dp) - ) - - if (cInfo is ChatInfo.Direct) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Row(Modifier.padding(horizontal = 32.dp)) { - ServerImage(chat) - Text( - chat.serverInfo.networkStatus.statusString, - textAlign = TextAlign.Center, - color = MaterialTheme.colors.onBackground, - modifier = Modifier.padding(start = 8.dp) - ) - } - Text( - chat.serverInfo.networkStatus.statusExplanation, - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onBackground, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(top = 16.dp) - .padding(horizontal = 16.dp) - ) - if (connStats != null) { - SimplexServers("receiving via: ", connStats.rcvServers) - SimplexServers("sending via: ", connStats.sndServers) - } - } - - Spacer(Modifier.weight(1F)) - - Box(Modifier.padding(4.dp)) { - SimpleButton( - stringResource(R.string.clear_chat_button), - icon = Icons.Outlined.Restore, - color = WarningOrange, - click = clearChat - ) - } - Box( - Modifier - .padding(4.dp) - .padding(bottom = 32.dp) - ) { - SimpleButton( - stringResource(R.string.button_delete_contact), - icon = Icons.Outlined.Delete, - color = Color.Red, - click = deleteContact - ) - } - } else if (cInfo is ChatInfo.Group) { - Spacer(Modifier.weight(1F)) - - Box( - Modifier - .padding(4.dp) - .padding(bottom = 32.dp) - ) { - SimpleButton( - stringResource(R.string.clear_chat_button), - icon = Icons.Outlined.Restore, - color = WarningOrange, - click = clearChat - ) - } + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + Text( + cInfo.fullName, style = MaterialTheme.typography.h2, + color = MaterialTheme.colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) } } } @Composable -fun SimplexServers(text: String, servers: List?) { - if (servers != null) { - val info = text + servers.joinToString(separator = ", ") { it.substringAfter("@") } - Text(info, style = MaterialTheme.typography.body2) +fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) { + Row( + Modifier + .fillMaxSize() + .clickable { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.network_status), + networkStatus.statusExplanation + ) + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(stringResource(R.string.network_status)) + Icon( + Icons.Outlined.Info, + stringResource(R.string.network_status), + tint = MaterialTheme.colors.primary + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + networkStatus.statusString, + color = HighOrLowlight + ) + ServerImage(networkStatus) + } } } @Composable -fun ServerImage(chat: Chat) { - when (chat.serverInfo.networkStatus) { - is Chat.NetworkStatus.Connected -> - Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) - is Chat.NetworkStatus.Disconnected -> - Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight) - is Chat.NetworkStatus.Error -> - Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight) - else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight) +fun ServerImage(networkStatus: Chat.NetworkStatus) { + Box(Modifier.size(18.dp)) { + when (networkStatus) { + is Chat.NetworkStatus.Connected -> + Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) + is Chat.NetworkStatus.Disconnected -> + Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight) + is Chat.NetworkStatus.Error -> + Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight) + else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight) + } + } +} + +@Composable +fun SimplexServers(text: String, servers: List) { + val info = servers.joinToString(separator = ", ") { it.substringAfter("@") } + InfoRow(text, info) +} + +@Composable +fun ClearChatButton(clearChat: () -> Unit) { + Row( + Modifier + .fillMaxSize() + .clickable { clearChat() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Restore, + stringResource(R.string.clear_chat_button), + tint = WarningOrange + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.clear_chat_button), color = WarningOrange) + } +} + +@Composable +fun DeleteContactButton(deleteContact: () -> Unit) { + Row( + Modifier + .fillMaxSize() + .clickable { deleteContact() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Delete, + stringResource(R.string.button_delete_contact), + tint = Color.Red + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.button_delete_contact), color = Color.Red) } } @@ -216,7 +269,7 @@ fun PreviewChatInfoLayout() { serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT")) ), connStats = null, - close = {}, deleteContact = {}, clearChat = {} + deleteContact = {}, clearChat = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 05f0362bbd..8e214e239b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -26,14 +26,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* +import chat.simplex.app.views.chat.group.AddGroupMembersView +import chat.simplex.app.views.chat.group.GroupChatInfoView import chat.simplex.app.views.chat.item.ChatItemView import chat.simplex.app.views.chatlist.openChat +import chat.simplex.app.views.chatlist.populateGroupMembers import chat.simplex.app.views.helpers.* import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding @@ -90,12 +94,28 @@ fun ChatView(chatModel: ChatModel) { back = { chatModel.chatId.value = null }, info = { withApi { - var connStats: ConnectionStats? = null val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { - connStats = chatModel.controller.apiContactInfo(cInfo.apiId) + val connStats = chatModel.controller.apiContactInfo(cInfo.apiId) + ModalManager.shared.showCustomModal { close -> + ModalView( + close = close, modifier = Modifier, + background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + ) { + ChatInfoView(chatModel, connStats, close) + } + } + } else if (cInfo is ChatInfo.Group) { + populateGroupMembers(cInfo.groupInfo, chatModel) + ModalManager.shared.showCustomModal { close -> + ModalView( + close = close, modifier = Modifier, + background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + ) { + GroupChatInfoView(cInfo.groupInfo, chatModel, close) + } + } } - ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, connStats, close) } } }, openDirectChat = { contactId -> @@ -137,6 +157,19 @@ fun ChatView(chatModel: ChatModel) { } else { chatModel.callManager.acceptIncomingCall(invitation = invitation) } + }, + addMembers = { groupInfo -> + withApi { + populateGroupMembers(groupInfo, chatModel) + ModalManager.shared.showCustomModal { close -> + ModalView( + close = close, modifier = Modifier, + background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + ) { + AddGroupMembersView(groupInfo, chatModel, close) + } + } + } } ) } @@ -160,7 +193,8 @@ fun ChatLayout( receiveFile: (Long) -> Unit, joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, - acceptCall: (Contact) -> Unit + acceptCall: (Contact) -> Unit, + addMembers: (GroupInfo) -> Unit ) { Surface( Modifier @@ -181,7 +215,7 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> @@ -195,7 +229,13 @@ fun ChatLayout( } @Composable -fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) { +fun ChatInfoToolbar( + chat: Chat, + back: () -> Unit, + info: () -> Unit, + startCall: (CallMediaType) -> Unit, + addMembers: (GroupInfo) -> Unit +) { @Composable fun toolbarButton(icon: ImageVector, @StringRes textId: Int, modifier: Modifier = Modifier.padding(0.dp), onClick: () -> Unit) { IconButton(onClick, modifier = modifier) { Icon(icon, stringResource(textId), tint = MaterialTheme.colors.primary) @@ -223,37 +263,53 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: ( startCall(CallMediaType.Video) } } - } - Row( - Modifier - .padding(horizontal = 80.dp) - .fillMaxWidth() - .clickable(onClick = info), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - ChatInfoImage(cInfo, size = 40.dp) - Column( - Modifier.padding(start = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - cInfo.displayName, fontWeight = FontWeight.SemiBold, - maxLines = 1, overflow = TextOverflow.Ellipsis - ) - if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { - Text( - cInfo.fullName, - maxLines = 1, overflow = TextOverflow.Ellipsis - ) + } else if (cInfo is ChatInfo.Group) { + if (cInfo.groupInfo.canAddMembers) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + toolbarButton(Icons.Outlined.PersonAdd, R.string.icon_descr_add_members) { + addMembers(cInfo.groupInfo) + } } } } + Box( + Modifier + .padding(horizontal = 80.dp).fillMaxWidth() + .clickable(onClick = info), + contentAlignment = Alignment.Center + ) { + ChatInfoToolbarTitle(cInfo) + } } Divider() } } +@Composable +fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondary) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + ChatInfoImage(cInfo, size = imageSize, iconColor) + Column( + Modifier.padding(start = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + cInfo.displayName, fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + Text( + cInfo.fullName, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } + } + } +} + data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState) val CIListStateSaver = run { @@ -402,7 +458,8 @@ fun PreviewChatLayout() { receiveFile = {}, joinGroup = {}, startCall = {}, - acceptCall = { _ -> } + acceptCall = { _ -> }, + addMembers = { _ -> } ) } } @@ -450,7 +507,8 @@ fun PreviewGroupChatLayout() { receiveFile = {}, joinGroup = {}, startCall = {}, - acceptCall = { _ -> } + acceptCall = { _ -> }, + addMembers = { _ -> } ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt new file mode 100644 index 0000000000..282ffa7c71 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/AddGroupMembersView.kt @@ -0,0 +1,300 @@ +package chat.simplex.app.views.chat.group + +import SectionCustomFooter +import SectionDivider +import SectionItemView +import SectionSpacer +import SectionView +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.ChatInfoToolbarTitle +import chat.simplex.app.views.helpers.* + +@Composable +fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { + val selectedContacts = remember { mutableStateListOf() } + val selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) } + + BackHandler(onBack = close) + AddGroupMembersLayout( + groupInfo = groupInfo, + contactsToAdd = getContactsToAdd(chatModel), + selectedContacts = selectedContacts, + selectedRole = selectedRole, + inviteMembers = { + withApi { + selectedContacts.forEach { + chatModel.controller.apiAddMember(groupInfo.groupId, it, selectedRole.value) + } + close.invoke() + } + }, + clearSelection = { selectedContacts.clear() }, + addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) }, + removeContact = { contactId -> selectedContacts.removeIf { it == contactId } }, + ) +} + +fun getContactsToAdd(chatModel: ChatModel): List { + val memberContactIds = chatModel.groupMembers + .filter { it.memberCurrent } + .mapNotNull { it.memberContactId } + return chatModel.chats + .asSequence() + .map { it.chatInfo } + .filterIsInstance() + .map { it.contact } + .filter { it.contactId !in memberContactIds } + .sortedBy { it.displayName.lowercase() } + .toList() +} + +@Composable +fun AddGroupMembersLayout( + groupInfo: GroupInfo, + contactsToAdd: List, + selectedContacts: SnapshotStateList, + selectedRole: MutableState, + inviteMembers: () -> Unit, + clearSelection: () -> Unit, + addContact: (Long) -> Unit, + removeContact: (Long) -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start, + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + ChatInfoToolbarTitle(ChatInfo.Group(groupInfo), imageSize = 60.dp, iconColor = HighOrLowlight) // TODO tertiary color + } + SectionSpacer() + + SectionView { + SectionItemView { + RoleSelectionRow(groupInfo, selectedRole) + } + SectionDivider() + SectionItemView { + InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty()) + } + } + SectionCustomFooter { + InviteSectionFooter(selectedContactsCount = selectedContacts.count(), clearSelection) + } + SectionSpacer() + + SectionView { + ContactList(contacts = contactsToAdd, selectedContacts, addContact, removeContact) + } + SectionSpacer() + } +} + +@Composable +fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(R.string.new_member_role)) + RoleDropdownMenu(groupInfo, selectedRole) + } +} + +@Composable +fun RoleDropdownMenu(groupInfo: GroupInfo, selectedRole: MutableState) { + val options = GroupMemberRole.values() + .filter { it <= groupInfo.membership.memberRole } + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + expanded = !expanded + } + ) { + Row( + Modifier.fillMaxWidth(0.7f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + Text( + selectedRole.value.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = HighOrLowlight + ) + Spacer(Modifier.size(4.dp)) + Icon( + if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess, + generalGetString(R.string.invite_to_group_button), + modifier = Modifier.padding(start = 8.dp), + tint = HighOrLowlight + ) + } + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + } + ) { + options.forEach { selectionOption -> + DropdownMenuItem( + onClick = { + selectedRole.value = selectionOption + expanded = false + } + ) { + Text( + selectionOption.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +fun InviteMembersButton(inviteMembers: () -> Unit, disabled: Boolean) { + val modifier = if (disabled) Modifier else Modifier.clickable { inviteMembers() } + Row( + modifier.fillMaxSize(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + val color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary + Text(stringResource(R.string.invite_to_group_button), color = color) + Spacer(Modifier.size(8.dp)) + Icon( + Icons.Outlined.Check, + stringResource(R.string.invite_to_group_button), + tint = color + ) + } +} + +@Composable +fun InviteSectionFooter(selectedContactsCount: Int, clearSelection: () -> Unit) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = if (selectedContactsCount >= 1) Arrangement.SpaceBetween else Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if (selectedContactsCount >= 1) { + Box( + Modifier.clickable { clearSelection() } + ) { + Text( + stringResource(R.string.clear_contacts_selection_button), + color = MaterialTheme.colors.primary, + fontSize = 12.sp + ) + } + + Text( + String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount), + color = HighOrLowlight, + fontSize = 12.sp + ) + } else { + Text( + stringResource(R.string.no_contacts_selected), + color = HighOrLowlight, + fontSize = 12.sp + ) + } + } +} + +@Composable +fun ContactList( + contacts: List, + selectedContacts: SnapshotStateList, + addContact: (Long) -> Unit, + removeContact: (Long) -> Unit +) { + Column { + contacts.forEachIndexed { index, contact -> + SectionItemView { + ContactCheckRow( + contact, addContact, removeContact, + checked = selectedContacts.contains(contact.apiId) + ) + } + if (index < contacts.lastIndex) { + SectionDivider() + } + } + } +} + +@Composable +fun ContactCheckRow( + contact: Contact, + addContact: (Long) -> Unit, + removeContact: (Long) -> Unit, + checked: Boolean +) { + Row( + Modifier + .fillMaxSize() + .clickable { if (!checked) addContact(contact.apiId) else removeContact(contact.apiId) }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ProfileImage(size = 36.dp, contact.image) + Text(contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Icon( + if (checked) Icons.Filled.CheckCircle else Icons.Outlined.Circle, + contentDescription = stringResource(R.string.icon_descr_contact_checked), + tint = if (checked) MaterialTheme.colors.primary else HighOrLowlight + ) + } +} + +@Preview +@Composable +fun PreviewAddGroupMembersLayout() { + SimpleXTheme { + AddGroupMembersLayout( + groupInfo = GroupInfo.sampleData, + contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData), + selectedContacts = remember { mutableStateListOf() }, + selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) }, + inviteMembers = {}, + clearSelection = {}, + addContact = {}, + removeContact = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt new file mode 100644 index 0000000000..7448650846 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt @@ -0,0 +1,291 @@ +package chat.simplex.app.views.chat.group + +import InfoRow +import SectionDivider +import SectionItemView +import SectionSpacer +import SectionView +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.* +import chat.simplex.app.views.chatlist.populateGroupMembers +import chat.simplex.app.views.helpers.* + +@Composable +fun GroupChatInfoView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { + BackHandler(onBack = close) + val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + if (chat != null) { + GroupChatInfoLayout( + chat, + groupInfo, + members = chatModel.groupMembers.sortedBy { it.displayName.lowercase() }, + addMembers = { + withApi { + populateGroupMembers(groupInfo, chatModel) + ModalManager.shared.showCustomModal { close -> + ModalView( + close = close, modifier = Modifier, + background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + ) { + AddGroupMembersView(groupInfo, chatModel, close) + } + } + } + }, + showMemberInfo = { member -> + withApi { + val connStats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId) + ModalManager.shared.showCustomModal { close -> + ModalView( + close = close, modifier = Modifier, + background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight + ) { + GroupMemberInfoView(groupInfo, member, connStats, chatModel, close) + } + } + } + }, + deleteGroup = { deleteGroupDialog(chat.chatInfo, chatModel, close) }, + clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) }, + leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) } + ) + } +} + +fun deleteGroupDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.delete_group_question), + text = generalGetString(R.string.delete_group_for_all_members_cannot_undo_warning), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { + withApi { + val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) + if (r) { + chatModel.removeChat(chatInfo.id) + chatModel.chatId.value = null + chatModel.controller.ntfManager.cancelNotificationsForChat(chatInfo.id) + close?.invoke() + } + } + } + ) +} + +fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.leave_group_question), + text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved), + confirmText = generalGetString(R.string.leave_group_button), + onConfirm = { + withApi { + chatModel.controller.leaveGroup(groupInfo.groupId) + close?.invoke() + } + } + ) +} + +@Composable +fun GroupChatInfoLayout( + chat: Chat, + groupInfo: GroupInfo, + members: List, + addMembers: () -> Unit, + showMemberInfo: (GroupMember) -> Unit, + deleteGroup: () -> Unit, + clearChat: () -> Unit, + leaveGroup: () -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + ChatInfoHeader(chat.chatInfo) + } + SectionSpacer() + + SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) { + if (groupInfo.canAddMembers) { + SectionItemView { + AddMembersButton(addMembers) + } + SectionDivider() + } + SectionItemView(height = 50.dp) { + MemberRow(groupInfo.membership, user = true) + } + SectionDivider() + MembersList(members, showMemberInfo) + } + SectionSpacer() + + SectionView { + SectionItemView { + ClearChatButton(clearChat) + } + if (groupInfo.canDelete) { + SectionDivider() + SectionItemView { + DeleteGroupButton(deleteGroup) + } + } + if (groupInfo.membership.memberStatus != GroupMemberStatus.MemLeft) { + SectionDivider() + SectionItemView { + LeaveGroupButton(leaveGroup) + } + } + } + SectionSpacer() + + SectionView(title = stringResource(R.string.section_title_for_console)) { + InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName) + SectionDivider() + InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString()) + } + SectionSpacer() + } +} + +@Composable +fun AddMembersButton(addMembers: () -> Unit) { + Row( + Modifier + .fillMaxSize() + .clickable { addMembers() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Add, + stringResource(R.string.button_add_members), + tint = MaterialTheme.colors.primary + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.button_add_members), color = MaterialTheme.colors.primary) + } +} + +@Composable +fun MembersList(members: List, showMemberInfo: (GroupMember) -> Unit) { + // LazyColumn { + // itemsIndexed(members) { index, member -> + Column { + members.forEachIndexed { index, member -> + SectionItemView(height = 50.dp) { + MemberRow(member, showMemberInfo) + } + if (index < members.lastIndex) { + SectionDivider() + } + } + } +} + +@Composable +fun MemberRow(member: GroupMember, showMemberInfo: ((GroupMember) -> Unit)? = null, user: Boolean = false) { + val modifier = if (showMemberInfo != null) Modifier.clickable { showMemberInfo(member) } else Modifier + Row( + modifier.fillMaxSize(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ProfileImage(size = 46.dp, member.image) + Column { + Text(member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis) + val s = member.memberStatus.shortText + val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s + Text( + statusDescr, + color = HighOrLowlight, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + val role = member.memberRole + if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) { + Text(role.text, color = HighOrLowlight) + } + } +} + +@Composable +fun LeaveGroupButton(leaveGroup: () -> Unit) { + Row( + Modifier + .fillMaxSize() + .clickable { leaveGroup() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Logout, + stringResource(R.string.button_leave_group), + tint = Color.Red + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.button_leave_group), color = Color.Red) + } +} + +@Composable +fun DeleteGroupButton(deleteGroup: () -> Unit) { + Row( + Modifier + .fillMaxSize() + .clickable { deleteGroup() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Delete, + stringResource(R.string.button_delete_group), + tint = Color.Red + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.button_delete_group), color = Color.Red) + } +} + +@Preview +@Composable +fun PreviewGroupChatInfoLayout() { + SimpleXTheme { + GroupChatInfoLayout( + chat = Chat( + chatInfo = ChatInfo.Direct.sampleData, + chatItems = arrayListOf(), + serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT")) + ), + groupInfo = GroupInfo.sampleData, + members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), + addMembers = {}, showMemberInfo = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt new file mode 100644 index 0000000000..ae447a98d5 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt @@ -0,0 +1,181 @@ +package chat.simplex.app.views.chat.group + +import InfoRow +import SectionDivider +import SectionItemView +import SectionSpacer +import SectionView +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.SimplexServers +import chat.simplex.app.views.helpers.* + +@Composable +fun GroupMemberInfoView(groupInfo: GroupInfo, member: GroupMember, connStats: ConnectionStats?, chatModel: ChatModel, close: () -> Unit) { + BackHandler(onBack = close) + val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + if (chat != null) { + GroupMemberInfoLayout( + groupInfo, + member, + connStats, + removeMember = { removeMemberDialog(member, chatModel, close) } + ) + } +} + +fun removeMemberDialog(member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(R.string.button_remove_member), + text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone), + confirmText = generalGetString(R.string.delete_verb), + onConfirm = { + withApi { + chatModel.controller.apiRemoveMember(member.groupId, member.groupMemberId) + close?.invoke() + } + } + ) +} + +@Composable +fun GroupMemberInfoLayout( + groupInfo: GroupInfo, + member: GroupMember, + connStats: ConnectionStats?, + removeMember: () -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + GroupMemberInfoHeader(member) + } + SectionSpacer() + + SectionView(title = stringResource(R.string.member_info_section_title_member)) { + InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName) + val conn = member.activeConn + if (conn != null) { + SectionDivider() + val connLevelDesc = + if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct) + else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel) + InfoRow(stringResource(R.string.info_row_connection), connLevelDesc) + } + } + SectionSpacer() + + if (connStats != null) { + val rcvServers = connStats.rcvServers + val sndServers = connStats.sndServers + if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) { + SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) { + if (rcvServers != null && rcvServers.isNotEmpty()) { + SimplexServers(stringResource(R.string.receiving_via), rcvServers) + if (sndServers != null && sndServers.isNotEmpty()) { + SectionDivider() + SimplexServers(stringResource(R.string.sending_via), sndServers) + } + } else if (sndServers != null && sndServers.isNotEmpty()) { + SimplexServers(stringResource(R.string.sending_via), sndServers) + } + } + SectionSpacer() + } + } + + if (member.canRemove(userRole = groupInfo.membership.memberRole) && member.memberStatus != GroupMemberStatus.MemRemoved) { + SectionView { + SectionItemView { + RemoveMemberButton(removeMember) + } + } + SectionSpacer() + } + + SectionView(title = stringResource(R.string.section_title_for_console)) { + InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName) + SectionDivider() + InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString()) + } + SectionSpacer() + } +} + +@Composable +fun GroupMemberInfoHeader(member: GroupMember) { + Column( + Modifier.padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProfileImage(size = 192.dp, member.image, color = HighOrLowlight) + Text( + member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + color = MaterialTheme.colors.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (member.fullName != "" && member.fullName != member.displayName) { + Text( + member.fullName, style = MaterialTheme.typography.h2, + color = MaterialTheme.colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun RemoveMemberButton(removeMember: () -> Unit) { + Row( + Modifier + .fillMaxSize() + .clickable { removeMember() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Outlined.Delete, + stringResource(R.string.button_remove_member), + tint = Color.Red + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.button_remove_member), color = Color.Red) + } +} + +@Preview +@Composable +fun PreviewGroupMemberInfoLayout() { + SimpleXTheme { + GroupMemberInfoLayout( + groupInfo = GroupInfo.sampleData, + member = GroupMember.sampleData, + connStats = null, + removeMember = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index a2861a5798..9d1a7a0bbc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -18,6 +18,8 @@ import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.ui.theme.WarningOrange import chat.simplex.app.views.chat.* +import chat.simplex.app.views.chat.group.deleteGroupDialog +import chat.simplex.app.views.chat.group.leaveGroupDialog import chat.simplex.app.views.chat.item.ItemAction import chat.simplex.app.views.helpers.* import kotlinx.coroutines.delay @@ -94,29 +96,28 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { } } +suspend fun populateGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) { + val groupMembers = chatModel.controller.apiListMembers(groupInfo.groupId) + chatModel.groupMembers.clear() + chatModel.groupMembers.addAll(groupMembers) +} + @Composable fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { if (showMarkRead) { MarkReadChatAction(chat, chatModel, showMenu) } ClearChatAction(chat, chatModel, showMenu) - DeleteChatAction(chat, chatModel, showMenu) + DeleteContactAction(chat, chatModel, showMenu) } @Composable fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> { - ItemAction( - stringResource(R.string.join_group_button), - Icons.Outlined.Login, - onClick = { - withApi { chatModel.controller.joinGroup(groupInfo.groupId) } - showMenu.value = false - } - ) + JoinGroupAction(groupInfo, chatModel, showMenu) if (groupInfo.canDelete) { - DeleteChatAction(chat, chatModel, showMenu) + DeleteGroupAction(chat, chatModel, showMenu) } } else -> { @@ -125,18 +126,10 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM } ClearChatAction(chat, chatModel, showMenu) if (groupInfo.membership.memberStatus != GroupMemberStatus.MemLeft) { - ItemAction( - stringResource(R.string.leave_group_button), - Icons.Outlined.Logout, - onClick = { - leaveGroupDialog(groupInfo, chatModel) - showMenu.value = false - }, - color = Color.Red - ) + LeaveGroupAction(groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { - DeleteChatAction(chat, chatModel, showMenu) + DeleteGroupAction(chat, chatModel, showMenu) } } } @@ -169,12 +162,50 @@ fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { +fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { ItemAction( stringResource(R.string.delete_verb), Icons.Outlined.Delete, onClick = { - deleteChatDialog(chat.chatInfo, chatModel) + deleteContactDialog(chat.chatInfo, chatModel) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +fun DeleteGroupAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { + ItemAction( + stringResource(R.string.delete_verb), + Icons.Outlined.Delete, + onClick = { + deleteGroupDialog(chat.chatInfo, chatModel) + showMenu.value = false + }, + color = Color.Red + ) +} + +@Composable +fun JoinGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { + ItemAction( + stringResource(R.string.join_group_button), + Icons.Outlined.Login, + onClick = { + withApi { chatModel.controller.joinGroup(groupInfo.groupId) } + showMenu.value = false + } + ) +} + +@Composable +fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { + ItemAction( + stringResource(R.string.leave_group_button), + Icons.Outlined.Logout, + onClick = { + leaveGroupDialog(groupInfo, chatModel) showMenu.value = false }, color = Color.Red diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index a3fc3ada33..1413b33176 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -8,8 +8,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Report -import androidx.compose.material.icons.outlined.Menu -import androidx.compose.material.icons.outlined.PersonAdd +import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -133,7 +132,7 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) { if (!stopped) { IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { Icon( - Icons.Outlined.PersonAdd, + Icons.Outlined.AddCircle, stringResource(R.string.add_contact), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt index b68a82b215..9bc80a4a9a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/ChatArchiveView.kt @@ -1,5 +1,8 @@ package chat.simplex.app.views.database +import SectionDivider +import SectionTextFooter +import SectionView import android.content.Context import android.content.res.Configuration import android.net.Uri @@ -25,8 +28,7 @@ import chat.simplex.app.TAG import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.usersettings.SettingsActionItem -import chat.simplex.app.views.usersettings.SettingsSectionView +import chat.simplex.app.views.usersettings.* import kotlinx.datetime.* import java.io.BufferedOutputStream import java.io.File @@ -57,21 +59,20 @@ fun ChatArchiveLayout( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start, ) { - @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) Text( title, Modifier.padding(start = 16.dp, bottom = 24.dp), style = MaterialTheme.typography.h1 ) - SettingsSectionView(stringResource(R.string.chat_archive_section)) { + SectionView(stringResource(R.string.chat_archive_section)) { SettingsActionItem( Icons.Outlined.IosShare, stringResource(R.string.save_archive), saveArchive, textColor = MaterialTheme.colors.primary ) - divider() + SectionDivider() SettingsActionItem( Icons.Outlined.Delete, stringResource(R.string.delete_archive), @@ -80,7 +81,7 @@ fun ChatArchiveLayout( ) } val archiveTs = SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US).format(Date.from(archiveTime.toJavaInstant())) - SettingsSectionFooter( + SectionTextFooter( String.format(generalGetString(R.string.archive_created_on_ts), archiveTs) ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt index 61d2ab1942..11c9438313 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/database/DatabaseView.kt @@ -1,5 +1,10 @@ package chat.simplex.app.views.database +import SectionDivider +import SectionTextFooter +import SectionItemView +import SectionSpacer +import SectionView import android.content.Context import android.content.res.Configuration import android.net.Uri @@ -23,7 +28,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* @@ -114,19 +118,18 @@ fun DatabaseLayout( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start, ) { - @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) Text( stringResource(R.string.your_chat_database), Modifier.padding(start = 16.dp, bottom = 24.dp), style = MaterialTheme.typography.h1 ) - SettingsSectionView(stringResource(R.string.run_chat_section)) { + SectionView(stringResource(R.string.run_chat_section)) { RunChatSetting(runChat, stopped, chatDbChanged, startChat, stopChatAlert) } - Spacer(Modifier.height(30.dp)) + SectionSpacer() - SettingsSectionView(stringResource(R.string.chat_database_section)) { + SectionView(stringResource(R.string.chat_database_section)) { SettingsActionItem( Icons.Outlined.IosShare, stringResource(R.string.export_database), @@ -134,7 +137,7 @@ fun DatabaseLayout( textColor = MaterialTheme.colors.primary, disabled = operationsDisabled ) - divider() + SectionDivider() SettingsActionItem( Icons.Outlined.FileDownload, stringResource(R.string.import_database), @@ -142,7 +145,7 @@ fun DatabaseLayout( textColor = Color.Red, disabled = operationsDisabled ) - divider() + SectionDivider() val chatArchiveNameVal = chatArchiveName.value val chatArchiveTimeVal = chatArchiveTime.value val chatLastStartVal = chatLastStart.value @@ -154,7 +157,7 @@ fun DatabaseLayout( click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) }, disabled = operationsDisabled ) - divider() + SectionDivider() } SettingsActionItem( Icons.Outlined.DeleteForever, @@ -164,7 +167,7 @@ fun DatabaseLayout( disabled = operationsDisabled ) } - SettingsSectionFooter( + SectionTextFooter( if (chatDbChanged) { stringResource(R.string.restart_the_app_to_use_new_chat_database) } else { @@ -186,7 +189,7 @@ fun RunChatSetting( startChat: () -> Unit, stopChatAlert: () -> Unit ) { - SettingsItemView() { + SectionItemView() { Row(verticalAlignment = Alignment.CenterVertically) { val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running) Icon( @@ -225,11 +228,6 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String { return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive) } -@Composable -fun SettingsSectionFooter(text: String) { - Text(text, color = HighOrLowlight, modifier = Modifier.padding(start = 16.dp, top = 5.dp).fillMaxWidth(0.9F), fontSize = 12.sp) -} - private fun startChat(m: ChatModel, runChat: MutableState, chatLastStart: MutableState, context: Context) { withApi { try { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index 58310a2856..5a596cd5ec 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -24,11 +24,11 @@ import chat.simplex.app.model.ChatInfo import chat.simplex.app.ui.theme.SimpleXTheme @Composable -fun ChatInfoImage(chatInfo: ChatInfo, size: Dp) { +fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondary) { val icon = if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle else Icons.Filled.AccountCircle - ProfileImage(size, chatInfo.image, icon) + ProfileImage(size, chatInfo.image, icon, iconColor) } @Composable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt new file mode 100644 index 0000000000..f77c179e57 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Section.kt @@ -0,0 +1,82 @@ +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.* +import chat.simplex.app.ui.theme.GroupDark +import chat.simplex.app.ui.theme.HighOrLowlight + +@Composable +fun SectionView(title: String? = null, content: (@Composable () -> Unit)) { + Column { + if (title != null) { + Text( + title, color = HighOrLowlight, style = MaterialTheme.typography.body2, + modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp + ) + } + Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) { + Column(Modifier.padding(horizontal = 6.dp).fillMaxWidth()) { content() } + } + } +} + +@Composable +fun SectionItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) { + val modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth() + .height(height) + Row( + if (click == null || disabled) modifier else modifier.clickable(onClick = click), + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + +@Composable +fun SectionTextFooter(text: String) { + Text( + text, + Modifier.padding(horizontal = 16.dp).padding(top = 5.dp).fillMaxWidth(0.9F), + color = HighOrLowlight, + fontSize = 12.sp + ) +} + +@Composable +fun SectionCustomFooter(content: (@Composable () -> Unit)) { + Row( + Modifier.padding(horizontal = 16.dp).padding(top = 5.dp) + ) { + content() + } +} + +@Composable +fun SectionDivider() { + Divider(Modifier.padding(horizontal = 8.dp)) +} + +@Composable +fun SectionSpacer() { + Spacer(Modifier.height(30.dp)) +} + +@Composable +fun InfoRow(title: String, value: String) { + SectionItemView { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(title) + Text(value, color = HighOrLowlight) + } + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt index a4bc591552..392da7086f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt @@ -29,13 +29,12 @@ fun SimpleButton(text: String, icon: ImageVector, } @Composable -fun SimpleButtonFrame(click: () -> Unit, content: @Composable () -> Unit) { +fun SimpleButtonFrame(click: () -> Unit, disabled: Boolean = false, content: @Composable () -> Unit) { Surface(shape = RoundedCornerShape(20.dp)) { + val modifier = if (disabled) Modifier else Modifier.clickable { click() } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable { click() } - .padding(8.dp) + modifier = modifier.padding(8.dp) ) { content() } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt index fac78c976c..95478fd9d9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/CallSettings.kt @@ -1,5 +1,8 @@ package chat.simplex.app.views.usersettings +import SectionDivider +import SectionItemView +import SectionView import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -29,18 +32,17 @@ fun CallSettingsLayout( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) val lockCallState = remember { mutableStateOf(callOnLockScreen.get()) } Text( stringResource(R.string.your_calls), Modifier.padding(start = 16.dp, bottom = 24.dp), style = MaterialTheme.typography.h1 ) - SettingsSectionView(stringResource(R.string.settings_section_title_settings)) { - Box(Modifier.padding(start = 10.dp)) { + SectionView(stringResource(R.string.settings_section_title_settings)) { + SectionItemView() { SharedPreferenceToggle(stringResource(R.string.connect_calls_via_relay), webrtcPolicyRelay) } - divider() + SectionDivider() Column(Modifier.padding(start = 10.dp, top = 12.dp)) { Text(stringResource(R.string.call_on_lock_screen)) @@ -75,8 +77,7 @@ fun SharedPreferenceToggle( colors = SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colors.primary, uncheckedThumbColor = HighOrLowlight - ), - modifier = Modifier.padding(end = 6.dp) + ) ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ExperimentalFeaturesView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ExperimentalFeaturesView.kt index 81b753a6db..3fb3ee9dbf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ExperimentalFeaturesView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/ExperimentalFeaturesView.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views.usersettings +import SectionView import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -25,7 +26,7 @@ fun ExperimentalFeaturesView(chatModel: ChatModel, enableCalls: MutableState Unit) { - @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start @@ -25,14 +27,14 @@ fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { style = MaterialTheme.typography.h1, modifier = Modifier.padding(start = 16.dp, bottom = 24.dp) ) - SettingsSectionView(stringResource(R.string.settings_section_title_device)) { + SectionView(stringResource(R.string.settings_section_title_device)) { ChatLockItem(chatModel.performLA, setPerformLA) } - Spacer(Modifier.height(30.dp)) + SectionSpacer() - SettingsSectionView(stringResource(R.string.settings_section_title_chats)) { + SectionView(stringResource(R.string.settings_section_title_chats)) { SettingsPreferenceItem(Icons.Outlined.Image, stringResource(R.string.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) - divider() + SectionDivider() SettingsPreferenceItem(Icons.Outlined.TravelExplore, stringResource(R.string.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index d269455cab..2dfdcd7ecb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -1,5 +1,9 @@ package chat.simplex.app.views.usersettings +import SectionDivider +import SectionItemView +import SectionSpacer +import SectionView import android.content.res.Configuration import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -79,6 +83,16 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit) { val simplexTeamUri = "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" +// TODO pass close +//fun showSectionedModal(chatModel: ChatModel, modalView: (@Composable (ChatModel) -> Unit)) { +// ModalManager.shared.showCustomModal { close -> +// ModalView(close = close, modifier = Modifier, +// background = if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) { +// modalView(chatModel) +// } +// } +//} + @Composable fun SettingsLayout( profile: Profile, @@ -101,79 +115,65 @@ fun SettingsLayout( .background(if (isSystemInDarkTheme()) MaterialTheme.colors.background else SettingsBackgroundLight) .padding(top = 16.dp) ) { - @Composable fun divider() = Divider(Modifier.padding(horizontal = 8.dp)) - @Composable fun spacer() = Spacer(Modifier.height(30.dp)) Text( stringResource(R.string.your_settings), style = MaterialTheme.typography.h1, modifier = Modifier.padding(start = 16.dp) ) - Spacer(Modifier.height(30.dp)) + SectionSpacer() - SettingsSectionView(stringResource(R.string.settings_section_title_you)) { - SettingsItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) { + SectionView(stringResource(R.string.settings_section_title_you)) { + SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, disabled = stopped) { ProfilePreview(profile, stopped = stopped) } - divider() + SectionDivider() SettingsActionItem(Icons.Outlined.QrCode, stringResource(R.string.your_simplex_contact_address), showModal { UserAddressView(it) }, disabled = stopped) - divider() + SectionDivider() DatabaseItem(showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped) } - spacer() + SectionSpacer() - SettingsSectionView(stringResource(R.string.settings_section_title_settings)) { + SectionView(stringResource(R.string.settings_section_title_settings)) { SettingsActionItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), showSettingsModal { CallSettingsView(it) }, disabled = stopped) - divider() + SectionDivider() SettingsActionItem(Icons.Outlined.Lock, stringResource(R.string.privacy_and_security), showSettingsModal { PrivacySettingsView(it, setPerformLA) }, disabled = stopped) - divider() + SectionDivider() PrivateNotificationsItem(runServiceInBackground, setRunServiceInBackground, stopped) - divider() + SectionDivider() SettingsActionItem(Icons.Outlined.Dns, stringResource(R.string.smp_servers), showModal { SMPServersView(it) }, disabled = stopped) - divider() + SectionDivider() SettingsActionItem(Icons.Outlined.SettingsEthernet, stringResource(R.string.network_settings), showNetworkSettings, disabled = stopped) } - spacer() + SectionSpacer() - SettingsSectionView(stringResource(R.string.settings_section_title_help)) { + SectionView(stringResource(R.string.settings_section_title_help)) { SettingsActionItem(Icons.Outlined.HelpOutline, stringResource(R.string.how_to_use_simplex_chat), showModal { HelpView(it) }, disabled = stopped) - divider() + SectionDivider() SettingsActionItem(Icons.Outlined.Info, stringResource(R.string.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }) - divider() + SectionDivider() SettingsActionItem(Icons.Outlined.TextFormat, stringResource(R.string.markdown_in_messages), showModal { MarkdownHelpView() }) - divider() + SectionDivider() SettingsActionItem(Icons.Outlined.Tag, stringResource(R.string.chat_with_the_founder), { uriHandler.openUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped) - divider() + SectionDivider() SettingsActionItem(Icons.Outlined.Email, stringResource(R.string.send_us_an_email), { uriHandler.openUri("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary) } - spacer() + SectionSpacer() - SettingsSectionView(stringResource(R.string.settings_section_title_develop)) { + SectionView(stringResource(R.string.settings_section_title_develop)) { ChatConsoleItem(showTerminal, stopped) - divider() + SectionDivider() InstallTerminalAppItem(uriHandler) - divider() + SectionDivider() // SettingsActionItem(Icons.Outlined.Science, stringResource(R.string.settings_experimental_features), showSettingsModal { ExperimentalFeaturesView(it, enableCalls) }) -// divider() +// SectionDivider() AppVersionItem() } } } } -@Composable fun SettingsSectionView(title: String, content: (@Composable () -> Unit)) { - Column { - Text( - title, color = HighOrLowlight, style = MaterialTheme.typography.body2, - modifier = Modifier.padding(start = 16.dp, bottom = 5.dp), fontSize = 12.sp - ) - Surface(color = if (isSystemInDarkTheme()) GroupDark else MaterialTheme.colors.background) { - Column(Modifier.padding(horizontal = 6.dp)) { content() } - } - } -} - @Composable private fun DatabaseItem(openDatabaseView: () -> Unit, stopped: Boolean) { - SettingsItemView(openDatabaseView) { + SectionItemView(openDatabaseView) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween @@ -204,7 +204,7 @@ fun SettingsLayout( setRunServiceInBackground: (Boolean) -> Unit, stopped: Boolean ) { - SettingsItemView(disabled = stopped) { + SectionItemView(disabled = stopped) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Outlined.Bolt, @@ -235,7 +235,7 @@ fun SettingsLayout( } @Composable fun ChatLockItem(performLA: MutableState, setPerformLA: (Boolean) -> Unit) { - SettingsItemView() { + SectionItemView() { Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Outlined.Lock, @@ -255,15 +255,14 @@ fun SettingsLayout( colors = SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colors.primary, uncheckedThumbColor = HighOrLowlight - ), - modifier = Modifier.padding(end = 6.dp) + ) ) } } } @Composable private fun ChatConsoleItem(showTerminal: () -> Unit, stopped: Boolean) { - SettingsItemView(showTerminal, disabled = stopped) { + SectionItemView(showTerminal, disabled = stopped) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), contentDescription = stringResource(R.string.chat_console), @@ -278,7 +277,7 @@ fun SettingsLayout( } @Composable private fun InstallTerminalAppItem(uriHandler: UriHandler) { - SettingsItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) { + SectionItemView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) { Icon( painter = painterResource(id = R.drawable.ic_github), contentDescription = "GitHub", @@ -290,7 +289,7 @@ fun SettingsLayout( } @Composable private fun AppVersionItem() { - SettingsItemView() { + SectionItemView() { Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") } } @@ -312,23 +311,9 @@ fun SettingsLayout( } } -@Composable -fun SettingsItemView(click: (() -> Unit)? = null, height: Dp = 46.dp, disabled: Boolean = false, content: (@Composable () -> Unit)) { - val modifier = Modifier - .padding(start = 8.dp) - .fillMaxWidth() - .height(height) - Row( - if (click == null || disabled) modifier else modifier.clickable(onClick = click), - verticalAlignment = Alignment.CenterVertically - ) { - content() - } -} - @Composable fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = null, textColor: Color = Color.Unspecified, disabled: Boolean = false) { - SettingsItemView(click, disabled = disabled) { + SectionItemView(click, disabled = disabled) { Icon(icon, text, tint = HighOrLowlight) Spacer(Modifier.padding(horizontal = 4.dp)) Text(text, color = if (disabled) HighOrLowlight else textColor) @@ -337,7 +322,7 @@ fun SettingsActionItem(icon: ImageVector, text: String, click: (() -> Unit)? = n @Composable fun SettingsPreferenceItem(icon: ImageVector, text: String, pref: Preference, prefState: MutableState? = null) { - SettingsItemView() { + SectionItemView() { Row(verticalAlignment = Alignment.CenterVertically) { Icon(icon, text, tint = HighOrLowlight) Spacer(Modifier.padding(horizontal = 4.dp)) diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index bad98fc2dc..71cfe72cc5 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -10,8 +10,9 @@ Соединиться - Соединение установлено - Соединение устанавливается… + соединено + ошибка + соединяется Установлено соединение с сервером, через который вы получаете сообщения от этого контакта. Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: %1$s). Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта. @@ -139,8 +140,8 @@ Ошибка сохранения файла - Удалить чат? - Чат и все сообщения будут удалены - это действие нельзя отменить! + Удалить контакт? + Контакт и все сообщения будут удалены - это действие нельзя отменить! Удалить контакт Соединение с сервером установлено Соединение с сервером не установлено @@ -506,6 +507,7 @@ Выйти из группы Вы перестанете получать сообщения от этой группы. История чата будет сохранена. [покинута] + Пригласить участников Вы отправили приглашение в группу @@ -524,4 +526,62 @@ удалил(а) группу вы удалили %1$s вы покинули группу + + + участник + администратор + владелец + + + удален(а) + покинул(а) + группа удалена + приглашен(а) + соединяется (представлен(а)) + соединяется (приглашение по представлению) + соединяется (приглашение принято) + соединяется (объявлен(а)) + соединен(а) + соединение завершено + создатель + + соединяется + + + Роль участника + Развернуть выбор роли + Пригласить в группу + Контакт выбран + Очистить + Контактов выбрано: %1$s + Контакты не выбраны + + + Пригласить участников + УЧАСТНИКОВ: %1$s + вы: %1$s + Удалить группу + Удалить группу? + Группа будет удалена для всех участников - это действие нельзя отменить! + Выйти из группы + + + ДЛЯ КОНСОЛИ + Локальное название + ID в базе данных + + + Удалить участника + Участник будет удален из группы - это действие нельзя отменить! + УЧАСТНИК + Группа + Соединение + прямое + непрямое (%1$s) + + + СЕРВЕРЫ + Получение через + Отправка через + Состояние сети diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 28abc66a00..ed7c8809d4 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -10,8 +10,9 @@ Connect - Server connected - Connecting server… + connected + error + connecting You are connected to the server used to receive messages from this contact. Trying to connect to the server used to receive messages from this contact (error: %1$s). Trying to connect to the server used to receive messages from this contact. @@ -139,8 +140,8 @@ Error saving file - Delete chat? - Chat and all messages will be deleted - this cannot be undone! + Delete contact? + Contact and all messages will be deleted - this cannot be undone! Delete contact Connected Disconnected @@ -508,6 +509,7 @@ Leave group? You will stop receiving messages from this group. Chat history will be preserved. [left] + Invite members You sent group invitation @@ -526,4 +528,62 @@ deleted group you removed %1$s you left + + + member + admin + owner + + + removed + left + group deleted + invited + connecting (introduced) + connecting (introduction invitation) + connecting (accepted) + connecting (announced) + connected + complete + creator + + connecting + + + New member role + Expand role selection + Invite to group + Contact checked + Clear + %1$s contact(s) selected + No contacts selected + + + Invite members + %1$s MEMBERS + you: %1$s + Delete group + Delete group? + Group will be deleted for all members - this cannot be undone! + Leave group + + + FOR CONSOLE + Local name + Database ID + + + Remove member + Member will be removed from group - this cannot be undone! + MEMBER + Group + Connection + direct + indirect (%1$s) + + + SERVERS + Receiving via + Sending via + Network status