android: groups ui (#850)

This commit is contained in:
JRoberts
2022-07-29 20:11:00 +04:00
committed by GitHub
parent ee6f6462cf
commit e87c78e997
21 changed files with 1490 additions and 284 deletions
@@ -26,8 +26,11 @@ class ChatModel(val controller: ChatController) {
val chatRunning = mutableStateOf<Boolean?>(null)
val chatDbChanged = mutableStateOf<Boolean>(false)
val chats = mutableStateListOf<Chat>()
// current chat
val chatId = mutableStateOf<String?>(null)
val chatItems = mutableStateListOf<ChatItem>()
val groupMembers = mutableStateListOf<GroupMember>()
var connReqInvitation: String? = null
val terminalItems = mutableStateListOf<TerminalItem>()
@@ -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
@@ -445,6 +445,13 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager
return null
}
suspend fun apiListContacts(): List<Contact>? {
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<GroupMember> {
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<Contact>): 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)
@@ -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<String>?) {
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<String>) {
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 = {}
)
}
}
@@ -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 = { _ -> }
)
}
}
@@ -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<Long>() }
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<Contact> {
val memberContactIds = chatModel.groupMembers
.filter { it.memberCurrent }
.mapNotNull { it.memberContactId }
return chatModel.chats
.asSequence()
.map { it.chatInfo }
.filterIsInstance<ChatInfo.Direct>()
.map { it.contact }
.filter { it.contactId !in memberContactIds }
.sortedBy { it.displayName.lowercase() }
.toList()
}
@Composable
fun AddGroupMembersLayout(
groupInfo: GroupInfo,
contactsToAdd: List<Contact>,
selectedContacts: SnapshotStateList<Long>,
selectedRole: MutableState<GroupMemberRole>,
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<GroupMemberRole>) {
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<GroupMemberRole>) {
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<Contact>,
selectedContacts: SnapshotStateList<Long>,
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 = {}
)
}
}
@@ -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<GroupMember>,
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<GroupMember>, 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 = {}
)
}
}
@@ -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 = {}
)
}
}
@@ -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<Boolean>, 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<Boolean>, 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<Boo
}
@Composable
fun DeleteChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
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<Boolean>) {
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<Boolean>) {
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<Boolean>) {
ItemAction(
stringResource(R.string.leave_group_button),
Icons.Outlined.Logout,
onClick = {
leaveGroupDialog(groupInfo, chatModel)
showMenu.value = false
},
color = Color.Red
@@ -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)
@@ -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)
)
}
@@ -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<Boolean>, chatLastStart: MutableState<Instant?>, context: Context) {
withApi {
try {
@@ -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
@@ -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)
}
}
}
@@ -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() }
}
}
@@ -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)
)
)
}
}
@@ -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<Boo
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 16.dp, bottom = 24.dp)
)
SettingsSectionView("") {
SectionView("") {
SettingsPreferenceItem(Icons.Outlined.Videocam, stringResource(R.string.settings_audio_video_calls), chatModel.controller.appPrefs.experimentalCalls, enableCalls)
}
}
@@ -1,5 +1,6 @@
package chat.simplex.app.views.usersettings
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -65,7 +66,7 @@ fun NetworkSettingsView(chatModel: ChatModel, netCfg: NetCfg) {
Modifier.padding(start = 16.dp, bottom = 24.dp),
style = MaterialTheme.typography.h1
)
SettingsSectionView(stringResource(R.string.settings_section_title_socks)) {
SectionView(stringResource(R.string.settings_section_title_socks)) {
Row(
Modifier.padding(start = 10.dp),
verticalAlignment = Alignment.CenterVertically
@@ -1,5 +1,8 @@
package chat.simplex.app.views.usersettings
import SectionDivider
import SectionSpacer
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -15,7 +18,6 @@ import chat.simplex.app.model.ChatModel
@Composable
fun PrivacySettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> 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)
}
}
@@ -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<Boolean>, 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<Boolean>, prefState: MutableState<Boolean>? = null) {
SettingsItemView() {
SectionItemView() {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, text, tint = HighOrLowlight)
Spacer(Modifier.padding(horizontal = 4.dp))
@@ -10,8 +10,9 @@
<string name="connect_via_link_verb">Соединиться</string>
<!-- Server info - ChatModel.kt -->
<string name="server_connected">Соединение установлено</string>
<string name="server_connecting">Соединение устанавливается…</string>
<string name="server_connected">соединено</string>
<string name="server_error">ошибка</string>
<string name="server_connecting">соединяется</string>
<string name="connected_to_server_to_receive_messages_from_contact">Установлено соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта (ошибка: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="trying_to_connect_to_server_to_receive_messages">Устанавливается соединение с сервером, через который вы получаете сообщения от этого контакта.</string>
@@ -139,8 +140,8 @@
<string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_chat_question">Удалить чат?</string>
<string name="delete_chat_all_messages_deleted_cannot_undo_warning">Чат и все сообщения будут удалены - это действие нельзя отменить!</string>
<string name="delete_contact_question">Удалить контакт?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Контакт и все сообщения будут удалены - это действие нельзя отменить!</string>
<string name="button_delete_contact">Удалить контакт</string>
<string name="icon_descr_server_status_connected">Соединение с сервером установлено</string>
<string name="icon_descr_server_status_disconnected">Соединение с сервером не установлено</string>
@@ -506,6 +507,7 @@
<string name="leave_group_question">Выйти из группы</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">Вы перестанете получать сообщения от этой группы. История чата будет сохранена.</string>
<string name="group_left_description">[покинута]</string>
<string name="icon_descr_add_members">Пригласить участников</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">Вы отправили приглашение в группу</string>
@@ -524,4 +526,62 @@
<string name="rcv_group_event_group_deleted">удалил(а) группу</string>
<string name="snd_group_event_member_deleted">вы удалили <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">вы покинули группу</string>
<!-- GroupMemberRole -->
<string name="group_member_role_member">участник</string>
<string name="group_member_role_admin">администратор</string>
<string name="group_member_role_owner">владелец</string>
<!-- GroupMemberStatus -->
<string name="group_member_status_removed">удален(а)</string>
<string name="group_member_status_left">покинул(а)</string>
<string name="group_member_status_group_deleted">группа удалена</string>
<string name="group_member_status_invited">приглашен(а)</string>
<string name="group_member_status_introduced">соединяется (представлен(а))</string>
<string name="group_member_status_intro_invitation">соединяется (приглашение по представлению)</string>
<string name="group_member_status_accepted">соединяется (приглашение принято)</string>
<string name="group_member_status_announced">соединяется (объявлен(а))</string>
<string name="group_member_status_connected">соединен(а)</string>
<string name="group_member_status_complete">соединение завершено</string>
<string name="group_member_status_creator">создатель</string>
<string name="group_member_status_connecting">соединяется</string>
<!-- AddGroupMembersView.kt -->
<string name="new_member_role">Роль участника</string>
<string name="icon_descr_expand_role">Развернуть выбор роли</string>
<string name="invite_to_group_button">Пригласить в группу</string>
<string name="icon_descr_contact_checked">Контакт выбран</string>
<string name="clear_contacts_selection_button">Очистить</string>
<string name="num_contacts_selected">Контактов выбрано: <xliff:g id="num_contacts">%1$s</xliff:g></string>
<string name="no_contacts_selected">Контакты не выбраны</string>
<!-- GroupChatInfoView.kt -->
<string name="button_add_members">Пригласить участников</string>
<string name="group_info_section_title_num_members">УЧАСТНИКОВ: <xliff:g id="num_members">%1$s</xliff:g></string>
<string name="group_info_member_you">вы: <xliff:g id="group_info_you">%1$s</xliff:g></string>
<string name="button_delete_group">Удалить группу</string>
<string name="delete_group_question">Удалить группу?</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Группа будет удалена для всех участников - это действие нельзя отменить!</string>
<string name="button_leave_group">Выйти из группы</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">ДЛЯ КОНСОЛИ</string>
<string name="info_row_local_name">Локальное название</string>
<string name="info_row_database_id">ID в базе данных</string>
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member">Удалить участника</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Участник будет удален из группы - это действие нельзя отменить!</string>
<string name="member_info_section_title_member">УЧАСТНИК</string>
<string name="info_row_group">Группа</string>
<string name="info_row_connection">Соединение</string>
<string name="conn_level_desc_direct">прямое</string>
<string name="conn_level_desc_indirect">непрямое (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<!-- ConnectionStats -->
<string name="conn_stats_section_title_servers">СЕРВЕРЫ</string>
<string name="receiving_via">Получение через</string>
<string name="sending_via">Отправка через</string>
<string name="network_status">Состояние сети</string>
</resources>
@@ -10,8 +10,9 @@
<string name="connect_via_link_verb">Connect</string>
<!-- Server info - ChatModel.kt -->
<string name="server_connected">Server connected</string>
<string name="server_connecting">Connecting server…</string>
<string name="server_connected">connected</string>
<string name="server_error">error</string>
<string name="server_connecting">connecting</string>
<string name="connected_to_server_to_receive_messages_from_contact">You are connected to the server used to receive messages from this contact.</string>
<string name="trying_to_connect_to_server_to_receive_messages_with_error">Trying to connect to the server used to receive messages from this contact (error: <xliff:g id="errorMsg">%1$s</xliff:g>).</string>
<string name="trying_to_connect_to_server_to_receive_messages">Trying to connect to the server used to receive messages from this contact.</string>
@@ -139,8 +140,8 @@
<string name="error_saving_file">Error saving file</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_chat_question">Delete chat?</string>
<string name="delete_chat_all_messages_deleted_cannot_undo_warning">Chat and all messages will be deleted - this cannot be undone!</string>
<string name="delete_contact_question">Delete contact?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contact and all messages will be deleted - this cannot be undone!</string>
<string name="button_delete_contact">Delete contact</string>
<string name="icon_descr_server_status_connected">Connected</string>
<string name="icon_descr_server_status_disconnected">Disconnected</string>
@@ -508,6 +509,7 @@
<string name="leave_group_question">Leave group?</string>
<string name="you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved">You will stop receiving messages from this group. Chat history will be preserved.</string>
<string name="group_left_description">[left]</string>
<string name="icon_descr_add_members">Invite members</string>
<!-- CIGroupInvitationView.kt -->
<string name="you_sent_group_invitation">You sent group invitation</string>
@@ -526,4 +528,62 @@
<string name="rcv_group_event_group_deleted">deleted group</string>
<string name="snd_group_event_member_deleted">you removed <xliff:g id="member profile" example="alice (Alice)">%1$s</xliff:g></string>
<string name="snd_group_event_user_left">you left</string>
<!-- GroupMemberRole -->
<string name="group_member_role_member">member</string>
<string name="group_member_role_admin">admin</string>
<string name="group_member_role_owner">owner</string>
<!-- GroupMemberStatus -->
<string name="group_member_status_removed">removed</string>
<string name="group_member_status_left">left</string>
<string name="group_member_status_group_deleted">group deleted</string>
<string name="group_member_status_invited">invited</string>
<string name="group_member_status_introduced">connecting (introduced)</string>
<string name="group_member_status_intro_invitation">connecting (introduction invitation)</string>
<string name="group_member_status_accepted">connecting (accepted)</string>
<string name="group_member_status_announced">connecting (announced)</string>
<string name="group_member_status_connected">connected</string>
<string name="group_member_status_complete">complete</string>
<string name="group_member_status_creator">creator</string>
<string name="group_member_status_connecting">connecting</string>
<!-- AddGroupMembersView.kt -->
<string name="new_member_role">New member role</string>
<string name="icon_descr_expand_role">Expand role selection</string>
<string name="invite_to_group_button">Invite to group</string>
<string name="icon_descr_contact_checked">Contact checked</string>
<string name="clear_contacts_selection_button">Clear</string>
<string name="num_contacts_selected"><xliff:g id="num_contacts">%1$s</xliff:g> contact(s) selected</string>
<string name="no_contacts_selected">No contacts selected</string>
<!-- GroupChatInfoView.kt -->
<string name="button_add_members">Invite members</string>
<string name="group_info_section_title_num_members"><xliff:g id="num_members">%1$s</xliff:g> MEMBERS</string>
<string name="group_info_member_you">you: <xliff:g id="group_info_you">%1$s</xliff:g></string>
<string name="button_delete_group">Delete group</string>
<string name="delete_group_question">Delete group?</string>
<string name="delete_group_for_all_members_cannot_undo_warning">Group will be deleted for all members - this cannot be undone!</string>
<string name="button_leave_group">Leave group</string>
<!-- For Console chat info section -->
<string name="section_title_for_console">FOR CONSOLE</string>
<string name="info_row_local_name">Local name</string>
<string name="info_row_database_id">Database ID</string>
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member">Remove member</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Member will be removed from group - this cannot be undone!</string>
<string name="member_info_section_title_member">MEMBER</string>
<string name="info_row_group">Group</string>
<string name="info_row_connection">Connection</string>
<string name="conn_level_desc_direct">direct</string>
<string name="conn_level_desc_indirect">indirect (<xliff:g id="conn_level">%1$s</xliff:g>)</string>
<!-- ConnectionStats -->
<string name="conn_stats_section_title_servers">SERVERS</string>
<string name="receiving_via">Receiving via</string>
<string name="sending_via">Sending via</string>
<string name="network_status">Network status</string>
</resources>