From 69ad3802452ccf19a6096258718d1e9be27ef26c Mon Sep 17 00:00:00 2001 From: Diogo Cunha Date: Mon, 15 Jul 2024 23:24:21 +0100 Subject: [PATCH] android, desktop: new chat UI improvements --- .../views/chatlist/ChatListNavLinkView.kt | 4 +- .../views/contacts/ContactListNavView.kt | 11 +- .../views/contacts/ContactPreviewView.kt | 15 ++ .../common/views/contacts/ContactsView.kt | 179 ++++++++++-------- .../common/views/newchat/NewChatSheet.kt | 8 +- .../commonMain/resources/MR/base/strings.xml | 3 +- 6 files changed, 133 insertions(+), 87 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index aa30e757ec..5c6b81c664 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -662,7 +662,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { } } -fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { +fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel, onSuccess: () -> Unit = {}) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.accept_connection_request__question), text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)), @@ -671,12 +671,14 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque SectionItemView({ AlertManager.shared.hideAlert() acceptContactRequest(rhId, incognito = false, contactRequest.apiId, contactRequest, true, chatModel) + onSuccess() }) { Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ AlertManager.shared.hideAlert() acceptContactRequest(rhId, incognito = true, contactRequest.apiId, contactRequest, true, chatModel) + onSuccess() }) { Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt index 549d4bdb47..b03a679ce8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt @@ -54,7 +54,16 @@ fun ContactListNavLinkView(chat: Chat, nextChatSelected: State, oneHand ContactPreviewView(chat, disabled) } }, - click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) }, + click = { + contactRequestAlertDialog( + chat.remoteHostId, + chat.chatInfo, + chatModel, + onSuccess = { + ModalManager.start.closeModals() + } + ) + }, dropdownMenuItems = { tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) { ContactRequestMenuItems(chat.remoteHostId, chat.chatInfo, chatModel, showMenu) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt index 38d99e9311..f2ad077063 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -3,6 +3,7 @@ package chat.simplex.common.views.contacts import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -99,6 +100,19 @@ fun ContactPreviewView( Spacer(Modifier.fillMaxWidth().weight(1f)) + if (chat.chatInfo is ChatInfo.ContactRequest) { + Text( + text = generalGetString(MR.strings.contact_type_new).uppercase(), + color = MaterialTheme.colors.onPrimary, + fontSize = 10.sp * fontSizeMultiplier, + modifier = Modifier + .background(MaterialTheme.colors.primary, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 4.dp) + .padding(vertical = 2.dp) + ) + } + if (chat.chatInfo.chatSettings?.favorite == true) { Icon( painterResource(MR.images.ic_star_filled), @@ -112,6 +126,7 @@ fun ContactPreviewView( } } + if (chat.chatInfo.incognito) { Icon( painterResource(MR.images.ic_theater_comedy), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactsView.kt index a64b47594d..1923e30316 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactsView.kt @@ -1,11 +1,15 @@ package chat.simplex.common.views.contacts +import SectionDividerSpaced +import SectionItemView import SectionView +import TextIconSpaced import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -24,6 +28,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale @@ -35,6 +40,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.toUpperCase import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -57,6 +63,7 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.platform.getKeyboardState import chat.simplex.common.platform.hideKeyboard import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.DetailedSMPStatsView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.strHasSingleSimplexLink import chat.simplex.res.MR @@ -64,110 +71,121 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import java.util.HashSet enum class ContactType { - RECENT, NEW, REMOVED + CARD, REQUEST, RECENT, CONNECTED_VIA_GROUP, REMOVED, UNKNOWN } -private fun contactChats(c: List, contactType: ContactType): List { - return c.filter { chat -> - when (val cInfo = chat.chatInfo) { - is ChatInfo.Direct -> when (contactType) { - ContactType.REMOVED -> cInfo.contact.contactStatus == ContactStatus.DeletedByUser || cInfo.contact.contactStatus == ContactStatus.Deleted - ContactType.NEW -> false - ContactType.RECENT -> cInfo.contact.contactStatus != ContactStatus.DeletedByUser && cInfo.contact.contactStatus != ContactStatus.Deleted +private fun contactChats(c: List, contactTypes: List): List { + return c.filter { chat -> contactTypes.contains(getContactType(chat)) } +} + +private fun getContactType(chat: Chat): ContactType { + return when (val cInfo = chat.chatInfo) { + is ChatInfo.ContactRequest -> ContactType.REQUEST + is ChatInfo.Direct -> { + val contact = cInfo.contact; + + when { + contact.activeConn == null && contact.profile.contactLink != null -> ContactType.CARD + contact.contactStatus == ContactStatus.DeletedByUser || contact.contactStatus == ContactStatus.Deleted -> ContactType.REMOVED + else -> ContactType.RECENT } - is ChatInfo.ContactRequest -> when (contactType) { - ContactType.NEW -> true - else -> false + } + is ChatInfo.Group -> { + when { + cInfo.groupInfo.sendMsgEnabled -> ContactType.CONNECTED_VIA_GROUP + else -> ContactType.UNKNOWN + } + } + else -> ContactType.UNKNOWN + } +} + +val chatsByTypeComparator = Comparator { chat1, chat2 -> + val chat1Type = getContactType(chat1) + val chat2Type = getContactType(chat2) + + when { + chat1Type.ordinal < chat2Type.ordinal -> -1 + chat1Type.ordinal > chat2Type.ordinal -> 1 + + else -> chat2.chatInfo.chatTs.compareTo(chat1.chatInfo.chatTs) + } +} + +@Composable +fun ContactActionsSection(contactActions: @Composable () -> Unit) { + contactActions() + Spacer(Modifier.height(DEFAULT_PADDING)) + + val archived = remember { contactChats(chatModel.chats, listOf(ContactType.REMOVED)) } + + if (archived.isNotEmpty()) { + SectionView { + SectionItemView( + click = { + // + } + ) { + Icon( + painterResource(MR.images.ic_folder_open), + contentDescription = stringResource(MR.strings.contact_type_deleted), + tint = MaterialTheme.colors.secondary, + ) + TextIconSpaced(extraPadding = true) + Text(text = stringResource(MR.strings.contact_type_deleted), color = MaterialTheme.colors.onBackground) } - else -> false } } } @Composable -fun ContactTypeTabs(contactActions: @Composable () -> Unit, searchText: MutableState) { - val scope = rememberCoroutineScope() - val selectedContactType = - remember { mutableStateOf(ContactType.RECENT) } - - val contactTypeTabTitles = ContactType.entries.map { - when (it) { - ContactType.NEW -> - stringResource(MR.strings.contact_type_new) - - ContactType.RECENT -> - stringResource(MR.strings.contact_type_recent) - - ContactType.REMOVED -> - stringResource(MR.strings.contact_type_removed) - } - } - - val contactTypePagerState = rememberPagerState( - initialPage = selectedContactType.value.ordinal, - initialPageOffsetFraction = 0f - ) { ContactType.entries.size } - - KeyChangeEffect(contactTypePagerState.currentPage) { - selectedContactType.value = ContactType.values()[contactTypePagerState.currentPage] - } +fun ContactsView(contactActions: @Composable () -> Unit) { val listState = rememberLazyListState(lazyListState.first, lazyListState.second) + var searchFocused by remember { mutableStateOf(false) } + val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf( + TextFieldValue("") + ) } + SectionView { Divider() - ContactsSearchBar(listState, searchText) + ContactsSearchBar( + listState = listState, + searchText = searchText, + focused = searchFocused, + onFocusChanged = { + searchFocused = it + } + ) Divider() } - contactActions() - TabRow( - selectedTabIndex = contactTypePagerState.currentPage, - backgroundColor = Color.Transparent, - contentColor = MaterialTheme.colors.primary, - ) { - contactTypeTabTitles.forEachIndexed { index, it -> - Tab( - selected = contactTypePagerState.currentPage == index, - onClick = { - scope.launch { - contactTypePagerState.animateScrollToPage(index) - } - }, - text = { Text(it, fontSize = 13.sp) }, - selectedContentColor = MaterialTheme.colors.primary, - unselectedContentColor = MaterialTheme.colors.secondary, - ) - } + if (!searchFocused) { + ContactActionsSection(contactActions) } - HorizontalPager( - state = contactTypePagerState, - Modifier.fillMaxSize(), - verticalAlignment = Alignment.Top, - userScrollEnabled = appPlatform.isAndroid - ) { index -> - val contactType = when (index) { - ContactType.NEW.ordinal -> ContactType.NEW - ContactType.RECENT.ordinal -> ContactType.RECENT - ContactType.REMOVED.ordinal -> ContactType.REMOVED - else -> ContactType.RECENT - } + Spacer(Modifier.height(DEFAULT_PADDING)) - ContactsList(listState = listState, chatModel = chatModel, searchText = searchText, contactType = contactType) + SectionView(title = stringResource(MR.strings.contact_list_header_title).uppercase(), padding = PaddingValues(DEFAULT_PADDING)) { + ContactsList(listState = listState, chatModel = chatModel, searchText = searchText, contactType = ContactType.RECENT) + } + + if (searchFocused) { + ContactActionsSection(contactActions) } } @Composable -fun ContactsSearchBar(listState: LazyListState, searchText: MutableState) { +fun ContactsSearchBar(listState: LazyListState, searchText: MutableState, focused: Boolean, onFocusChanged: (hasFocus: Boolean) -> Unit) { Row(verticalAlignment = Alignment.CenterVertically) { val focusRequester = remember { FocusRequester() } - var focused by remember { mutableStateOf(false) } Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF), tint = MaterialTheme.colors.secondary) SearchTextField( - Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester), + Modifier.weight(1f).onFocusChanged { onFocusChanged(it.hasFocus) }.focusRequester(focusRequester), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true, searchText = searchText, @@ -245,7 +263,7 @@ fun ContactsList(listState: LazyListState, chatModel: ChatModel, searchText: Mut } val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value - val allChats = remember { contactChats(chatModel.chats, contactType) } + val allChats = remember { contactChats(chatModel.chats, listOf(ContactType.CARD, ContactType.RECENT, ContactType.REQUEST)) } val filteredContactChats = filteredContactChats( showUnreadAndFavorites = showUnreadAndFavorites, @@ -302,14 +320,19 @@ private fun filteredContactChats( searchText: String, contactChats: List ): List { - val s = searchText.trim().lowercase() - return contactChats .filter { chat -> filterChat( chat = chat, searchText = searchText, showUnreadAndFavorites = showUnreadAndFavorites) } - .sortedByDescending { it.chatInfo.chatTs } + .distinctBy { chat -> + when (val cInfo = chat.chatInfo) { + is ChatInfo.ContactRequest -> cInfo.contactRequest.contactRequestId + is ChatInfo.Direct -> cInfo.contact.contactId + else -> cInfo.id + } + } + .sortedWith(chatsByTypeComparator) } private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean = diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 373f5d5741..98419a2b18 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -32,7 +32,7 @@ import chat.simplex.common.model.ChatModel import chat.simplex.common.model.RemoteHostInfo import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.contacts.ContactTypeTabs +import chat.simplex.common.views.contacts.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.flow.MutableStateFlow @@ -53,12 +53,8 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?) { bottomPadding = bottomPadding ) } - val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf( - TextFieldValue("") - ) } - ContactTypeTabs( - searchText = searchText, + ContactsView( contactActions = { NewChatOptions( addContact = { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 89284b7f93..68b62ade0d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2217,7 +2217,8 @@ New - Removed + Deleted contacts Recent No filtered contacts + Your contacts \ No newline at end of file