From cad1ad87a81099113ec54f62b97aeef59860567e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Wed, 17 Aug 2022 16:58:57 +0100 Subject: [PATCH] android: search (#940) * Search in chat messages * align api with core/swift * EOLs * DefaultTopAppBar changes Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 8 +- .../java/chat/simplex/app/ui/theme/Color.kt | 4 +- .../chat/simplex/app/views/chat/ChatView.kt | 172 ++++++++++++------ .../app/views/chat/item/ChatItemView.kt | 3 +- .../app/views/chatlist/ChatListNavLinkView.kt | 11 +- .../app/views/chatlist/ChatListView.kt | 82 ++++----- .../app/views/helpers/DefaultTopAppBar.kt | 124 +++++++++++++ .../app/views/helpers/SearchTextField.kt | 97 ++++++++++ .../app/src/main/res/values-ru/strings.xml | 1 + .../app/src/main/res/values/strings.xml | 1 + 10 files changed, 389 insertions(+), 114 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 7cf7053630..ada4d57020 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -314,8 +314,8 @@ open class ChatController(private val ctrl: ChatCtrl, val ntfManager: NtfManager throw Error("failed getting the list of chats: ${r.responseType} ${r.details}") } - suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT)): Chat? { - val r = sendCmd(CC.ApiGetChat(type, id, pagination)) + suspend fun apiGetChat(type: ChatType, id: Long, pagination: ChatPagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search: String = ""): Chat? { + val r = sendCmd(CC.ApiGetChat(type, id, pagination, search)) if (r is CR.ApiChat ) return r.chat Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}") return null @@ -1115,7 +1115,7 @@ sealed class CC { class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() class ApiGetChats: CC() - class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination): CC() + class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() @@ -1166,7 +1166,7 @@ sealed class CC { is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" is ApiGetChats -> "/_get chats pcc=on" - is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " $search") is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index 8f02fee808..fde270bb38 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -15,8 +15,8 @@ val DarkGray = Color(43, 44, 46, 255) val HighOrLowlight = Color(139, 135, 134, 255) val MessagePreviewDark = Color(179, 175, 174, 255) val MessagePreviewLight = Color(49, 45, 44, 255) -val ToolbarLight = Color(220, 220, 220, 20) -val ToolbarDark = Color(80, 80, 80, 20) +val ToolbarLight = Color(220, 220, 220, 12) +val ToolbarDark = Color(80, 80, 80, 12) val SettingsBackgroundLight = Color(220, 216, 215, 90) val SettingsSecondaryLight = Color(200, 196, 195, 90) val GroupDark = Color(80, 80, 80, 60) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 572c4adb93..fd297bcfbd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -2,7 +2,6 @@ package chat.simplex.app.views.chat import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes import androidx.compose.foundation.* import androidx.compose.foundation.gestures.* import androidx.compose.foundation.layout.* @@ -12,6 +11,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.mapSaver @@ -20,10 +20,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.* import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.* @@ -34,8 +35,10 @@ 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.chat.item.ItemAction import chat.simplex.app.views.chatlist.* import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.helpers.AppBarHeight import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.* @@ -45,6 +48,7 @@ import kotlinx.datetime.Clock @Composable fun ChatView(chatModel: ChatModel) { var activeChat by remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) } + val searchText = remember { mutableStateOf("") } val user = chatModel.currentUser.value val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) } @@ -83,6 +87,7 @@ fun ChatView(chatModel: ChatModel) { scope, attachmentBottomSheetState, chatModel.chatItems, + searchText, useLinkPreviews = useLinkPreviews, back = { chatModel.chatId.value = null }, info = { @@ -127,7 +132,9 @@ fun ChatView(chatModel: ChatModel) { val c = chatModel.getChat(cInfo.id) val firstId = chatModel.chatItems.firstOrNull()?.id if (c != null && firstId != null) { - withApi { apiLoadPrevMessages(firstId, c.chatInfo, chatModel) } + withApi { + apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value) + } } }, deleteMessage = { itemId, mode -> @@ -187,6 +194,14 @@ fun ChatView(chatModel: ChatModel) { range ) } + }, + onSearchValueChanged = { value -> + if (searchText.value == value) return@ChatLayout + val c = chatModel.getChat(chat.chatInfo.id) ?: return@ChatLayout + withApi { + apiFindMessages(c.chatInfo, chatModel, value) + searchText.value = value + } } ) } @@ -203,6 +218,7 @@ fun ChatLayout( scope: CoroutineScope, attachmentBottomSheetState: ModalBottomSheetState, chatItems: List, + searchValue: State, useLinkPreviews: Boolean, back: () -> Unit, info: () -> Unit, @@ -215,6 +231,7 @@ fun ChatLayout( acceptCall: (Contact) -> Unit, addMembers: (GroupInfo) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, + onSearchValueChanged: (String) -> Unit, ) { Surface( Modifier @@ -236,7 +253,7 @@ fun ChatLayout( ) { val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) } Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, onSearchValueChanged) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = floatingButton.value, @@ -248,7 +265,7 @@ fun ChatLayout( ) { BoxWithConstraints(Modifier.padding(contentPadding)) { ChatItemsList( - user, chat, unreadCount, composeState, chatItems, + user, chat, unreadCount, composeState, chatItems, searchValue, useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage, receiveFile, joinGroup, acceptCall, markRead, floatingButton ) @@ -266,54 +283,79 @@ fun ChatInfoToolbar( back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, - addMembers: (GroupInfo) -> Unit + addMembers: (GroupInfo) -> Unit, + onSearchValueChanged: (String) -> 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) + var showMenu by remember { mutableStateOf(false) } + var showSearch by remember { mutableStateOf(false) } + val onBackClicked = { + if (!showSearch) { + back() + } else { + onSearchValueChanged("") + showSearch = false } } - Column { - Box( - Modifier - .fillMaxWidth() - .height(52.dp) - .background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight) - .padding(horizontal = 4.dp), - contentAlignment = Alignment.CenterStart, - ) { - val cInfo = chat.chatInfo - toolbarButton(Icons.Outlined.ArrowBackIos, R.string.back, onClick = back) - if (cInfo is ChatInfo.Direct) { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { - Box(Modifier.width(85.dp), contentAlignment = Alignment.CenterStart) { - toolbarButton(Icons.Outlined.Phone, R.string.icon_descr_audio_call) { - startCall(CallMediaType.Audio) - } - } - toolbarButton(Icons.Outlined.Videocam, R.string.icon_descr_video_call) { - startCall(CallMediaType.Video) - } - } - } 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) + BackHandler(onBack = onBackClicked) + val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() + val menuItems = arrayListOf<@Composable () -> Unit>() + menuItems.add { + ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), Icons.Outlined.Search, onClick = { + showMenu = false + showSearch = true + }) + } + + if (chat.chatInfo is ChatInfo.Direct) { + barButtons.add { + IconButton({ + showMenu = false + startCall(CallMediaType.Audio) + }) { + Icon(Icons.Outlined.Phone, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary) } } - Divider() + menuItems.add { + ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), Icons.Outlined.Videocam, onClick = { + showMenu = false + startCall(CallMediaType.Video) + }) + } + } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) { + barButtons.add { + IconButton({ + showMenu = false + addMembers(chat.chatInfo.groupInfo) + }) { + Icon(Icons.Outlined.PersonAdd, stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary) + } + } + } + barButtons.add { + IconButton({ showMenu = true }) { + Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary) + } + } + + DefaultTopAppBar( + navigationButton = { NavigationButtonBack(onBackClicked) }, + title = { ChatInfoToolbarTitle(chat.chatInfo) }, + onTitleClick = info, + showSearch = showSearch, + onSearchValueChanged = onSearchValueChanged, + buttons = barButtons + ) + + Divider(Modifier.padding(top = AppBarHeight)) + + Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + Modifier.widthIn(min = 220.dp) + ) { + menuItems.forEach { it() } + } } } @@ -361,6 +403,7 @@ fun BoxWithConstraintsScope.ChatItemsList( unreadCount: State, composeState: MutableState, chatItems: List, + searchValue: State, useLinkPreviews: Boolean, openDirectChat: (Long) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, @@ -379,13 +422,19 @@ fun BoxWithConstraintsScope.ChatItemsList( // Helps to scroll to bottom after moving from Group to Direct chat // and prevents scrolling to bottom on orientation change var shouldAutoScroll by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType) { + LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType, shouldAutoScroll) { if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) { scope.launch { listState.scrollToItem(0) } } // Don't autoscroll next time until it will be needed shouldAutoScroll = false } + // Scroll to bottom when search value changes from something to nothing and back + LaunchedEffect(searchValue.value.isEmpty()) { + if (listState.firstVisibleItemIndex != 0) { + scope.launch { listState.scrollToItem(0) } + } + } PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, chat, chatItems) { c -> loadPrevMessages(c.chatInfo) @@ -473,7 +522,7 @@ fun BoxWithConstraintsScope.ChatItemsList( } } } - FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, markRead, floatingButton, listState) + FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, floatingButton, listState) } @Composable @@ -481,24 +530,26 @@ fun BoxWithConstraintsScope.FloatingButtons( chatItems: List, unreadCount: State, minUnreadItemId: Long, + searchValue: State, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, floatingButton: MutableState<@Composable () -> Unit>, listState: LazyListState ) { val scope = rememberCoroutineScope() + val bottomUnreadCount by remember { + derivedStateOf { + val from = chatItems.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex + if (chatItems.size <= from || from < 0) return@derivedStateOf 0 - val bottomUnreadCount by remember { derivedStateOf { - chatItems.subList( - chatItems.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex, - chatItems.size - ).count { it.isRcvNew } } + chatItems.subList(from, chatItems.size).count { it.isRcvNew } + } } val firstItemIsVisible by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt() LaunchedEffect(bottomUnreadCount, firstItemIsVisible) { - val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible + val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty() val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible floatingButton.value = bottomEndFloatingButton( bottomUnreadCount, @@ -512,7 +563,8 @@ fun BoxWithConstraintsScope.FloatingButtons( } ) } - + // Don't show top FAB if is in search + if (searchValue.value.isNotEmpty()) return val fabSize = 56.dp val topUnreadCount by remember { derivedStateOf { unreadCount.value - bottomUnreadCount } @@ -702,6 +754,7 @@ fun PreviewChatLayout() { ) ) val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } + val searchValue = remember { mutableStateOf("") } ChatLayout( user = User.sampleData, chat = Chat( @@ -716,6 +769,7 @@ fun PreviewChatLayout() { scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, + searchValue, useLinkPreviews = true, back = {}, info = {}, @@ -728,6 +782,7 @@ fun PreviewChatLayout() { acceptCall = { _ -> }, addMembers = { _ -> }, markRead = { _, _ -> }, + onSearchValueChanged = {}, ) } } @@ -755,6 +810,7 @@ fun PreviewGroupChatLayout() { ) ) val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } + val searchValue = remember { mutableStateOf("") } ChatLayout( user = User.sampleData, chat = Chat( @@ -769,6 +825,7 @@ fun PreviewGroupChatLayout() { scope = rememberCoroutineScope(), attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), chatItems = chatItems, + searchValue, useLinkPreviews = true, back = {}, info = {}, @@ -781,6 +838,7 @@ fun PreviewGroupChatLayout() { acceptCall = { _ -> }, addMembers = { _ -> }, markRead = { _, _ -> }, + onSearchValueChanged = {}, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 19732416a4..7392526c39 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -164,7 +164,8 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo text, modifier = Modifier .fillMaxWidth() - .weight(1F), + .weight(1F) + .padding(end = 15.dp), color = color ) Icon(icon, text, tint = color) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index 6e3ec9f3a5..ade4c9abe8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -96,11 +96,16 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { } } -suspend fun apiLoadPrevMessages(beforeChatItemId: Long, chatInfo: ChatInfo, chatModel: ChatModel) { +suspend fun apiLoadPrevMessages(chatInfo: ChatInfo, chatModel: ChatModel, beforeChatItemId: Long, search: String) { val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT) - val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination) ?: return + val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return + chatModel.chatItems.addAll(0, chat.chatItems) +} + +suspend fun apiFindMessages(chatInfo: ChatInfo, chatModel: ChatModel, search: String) { + val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId, search = search) ?: return + chatModel.chatItems.clear() chatModel.chatItems.addAll(0, chat.chatItems) - chatModel.chatId.value = chatInfo.id } suspend fun setGroupMembers(groupInfo: GroupInfo, chatModel: ChatModel) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index aeff3cc29c..a6f1029f34 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -10,7 +10,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.outlined.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource @@ -18,10 +17,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.ChatModel -import chat.simplex.app.ui.theme.ToolbarDark -import chat.simplex.app.ui.theme.ToolbarLight -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.generalGetString +import chat.simplex.app.views.helpers.* import chat.simplex.app.views.newchat.NewChatSheet import chat.simplex.app.views.onboarding.MakeConnection import chat.simplex.app.views.usersettings.SettingsView @@ -72,6 +68,7 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: if (chatModel.clearOverlays.value && scaffoldCtrl.expanded.value) scaffoldCtrl.collapse() } BottomSheetScaffold( + topBar = { ChatListToolbar(scaffoldCtrl, stopped) }, scaffoldState = scaffoldCtrl.state, drawerContent = { SettingsView(chatModel, setPerformLA) }, sheetPeekHeight = 0.dp, @@ -84,8 +81,6 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: .fillMaxSize() .background(MaterialTheme.colors.background) ) { - ChatListToolbar(scaffoldCtrl, stopped) - Divider() if (chatModel.chats.isNotEmpty()) { ChatList(chatModel) } else { @@ -106,49 +101,42 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: @Composable fun ChatListToolbar(scaffoldCtrl: ScaffoldController, stopped: Boolean) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .height(52.dp) - .background(if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight) - .padding(horizontal = 8.dp) - ) { - IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) { - Icon( - Icons.Outlined.Menu, - stringResource(R.string.icon_descr_settings), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) + DefaultTopAppBar( + navigationButton = { NavigationButtonMenu { scaffoldCtrl.toggleDrawer() } }, + title = { + Text( + stringResource(R.string.your_chats), + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.SemiBold, ) - } - Text( - stringResource(R.string.your_chats), - color = MaterialTheme.colors.onBackground, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(5.dp) - ) - if (!stopped) { - IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { - Icon( - Icons.Outlined.AddCircle, - stringResource(R.string.add_contact), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp).size(26.dp) - ) - } - } else { - IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) { - Icon( - Icons.Filled.Report, - generalGetString(R.string.chat_is_stopped_indication), - tint = Color.Red, - modifier = Modifier.padding(10.dp) - ) + }, + onTitleClick = null, + showSearch = false, + onSearchValueChanged = {}, + buttons = listOf{ + if (!stopped) { + IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { + Icon( + Icons.Outlined.AddCircle, + stringResource(R.string.add_contact), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp).size(26.dp) + ) + } + } else { + IconButton(onClick = { AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_is_stopped_indication), + generalGetString(R.string.you_can_start_chat_via_setting_or_by_restarting_the_app)) }) { + Icon( + Icons.Filled.Report, + generalGetString(R.string.chat_is_stopped_indication), + tint = Color.Red, + modifier = Modifier.padding(10.dp) + ) + } } } - } + ) + Divider() } @Composable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt new file mode 100644 index 0000000000..1e6c37902a --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/DefaultTopAppBar.kt @@ -0,0 +1,124 @@ +package chat.simplex.app.views.helpers + +import chat.simplex.app.R +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.ArrowBackIos +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.app.ui.theme.ToolbarDark +import chat.simplex.app.ui.theme.ToolbarLight + +@Composable +fun DefaultTopAppBar( + navigationButton: @Composable RowScope.() -> Unit, + title: @Composable () -> Unit, + onTitleClick: (() -> Unit)? = null, + showSearch: Boolean, + onSearchValueChanged: (String) -> Unit, + buttons: List<@Composable RowScope.() -> Unit> = emptyList(), +) { + // If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier + val modifier = if (!showSearch) { + Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { }) + } else Modifier + + TopAppBar( + modifier = modifier, + title = { + if (!showSearch) { + title() + } else { + SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged) + } + }, + backgroundColor = if (isSystemInDarkTheme()) ToolbarDark else ToolbarLight, + navigationIcon = navigationButton, + buttons = if (!showSearch) buttons else emptyList(), + centered = !showSearch + ) +} + +@Composable +fun NavigationButtonBack(onButtonClicked: () -> Unit) { + IconButton(onButtonClicked) { + Icon( + Icons.Outlined.ArrowBackIos, stringResource(R.string.back), tint = MaterialTheme.colors.primary + ) + } +} + +@Composable +fun NavigationButtonMenu(onButtonClicked: () -> Unit) { + IconButton(onClick = onButtonClicked) { + Icon( + Icons.Outlined.Menu, + stringResource(R.string.icon_descr_settings), + tint = MaterialTheme.colors.primary, + ) + } +} + +@Composable +private fun TopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + navigationIcon: @Composable (RowScope.() -> Unit)? = null, + buttons: List<@Composable RowScope.() -> Unit> = emptyList(), + backgroundColor: Color = MaterialTheme.colors.primarySurface, + centered: Boolean, +) { + Box( + modifier + .fillMaxWidth() + .height(AppBarHeight) + .background(backgroundColor) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart, + ) { + if (navigationIcon != null) { + Row( + Modifier + .fillMaxHeight() + .width(TitleInsetWithIcon - AppBarHorizontalPadding), + verticalAlignment = Alignment.CenterVertically, + content = navigationIcon + ) + } + + Row( + Modifier + .fillMaxHeight() + .fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + buttons.forEach { it() } + } + val startPadding = if (navigationIcon != null) TitleInsetWithIcon else TitleInsetWithoutIcon + val endPadding = (buttons.size * 50f).dp + Box( + Modifier + .fillMaxWidth() + .padding( + start = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else startPadding, + end = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else endPadding + ), + contentAlignment = Alignment.Center + ) { + title() + } + } +} + +val AppBarHeight = 56.dp +private val AppBarHorizontalPadding = 4.dp +private val TitleInsetWithoutIcon = 16.dp - AppBarHorizontalPadding +private val TitleInsetWithIcon = 72.dp diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt new file mode 100644 index 0000000000..b2fc5d6da0 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SearchTextField.kt @@ -0,0 +1,97 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.foundation.text.* +import androidx.compose.material.* +import androidx.compose.material.TextFieldDefaults.indicatorLine +import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.* +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.* +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import kotlinx.coroutines.delay + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun SearchTextField(modifier: Modifier, placeholder: String, onValueChange: (String) -> Unit) { + var searchText by remember { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + delay(200) + keyboard?.show() + } + + val enabled = true + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Unspecified, + textColor = MaterialTheme.colors.onBackground, + focusedIndicatorColor = Color.Unspecified, + unfocusedIndicatorColor = Color.Unspecified, + ) + val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize) + val interactionSource = remember { MutableInteractionSource() } + BasicTextField( + value = searchText, + modifier = modifier + .background(colors.backgroundColor(enabled).value, shape) + .indicatorLine(enabled, false, interactionSource, colors) + .focusRequester(focusRequester) + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = TextFieldDefaults.MinHeight + ), + onValueChange = { + searchText = it + onValueChange(it) + }, + cursorBrush = SolidColor(colors.cursorColor(false).value), + visualTransformation = VisualTransformation.None, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + singleLine = true, + textStyle = TextStyle( + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ), + interactionSource = interactionSource, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = searchText, + innerTextField = innerTextField, + placeholder = { + Text(placeholder) + }, + trailingIcon = if (searchText.isNotEmpty()) {{ + IconButton({ searchText = ""; onValueChange("") }) { + Icon(Icons.Default.Close, stringResource(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary,) + } + }} else null, + singleLine = true, + enabled = enabled, + interactionSource = interactionSource, + contentPadding = textFieldWithLabelPadding(start = 0.dp, end = 0.dp), + visualTransformation = VisualTransformation.None, + colors = colors + ) + } + ) +} diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 8d483270c2..6aaeed14be 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -233,6 +233,7 @@ SimpleX команда SimpleX логотип Email + Больше Неверный QR код diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index c8a26ff348..90d7fa256b 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -233,6 +233,7 @@ SimpleX Team SimpleX Logo Email + More Invalid QR code