Floating button with unread counter and go to bottom action (#929)
* Floating buttons with unread counters and go to bottom action * Fixed marking read of long chats without preloaded messages * Apply suggestions from code review * Counters fix * update button size/color Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
aac80dacf7
commit
5d8d636adc
@@ -211,7 +211,7 @@ class ChatModel(val controller: ChatController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null) {
|
fun markChatItemsRead(cInfo: ChatInfo, range: CC.ItemRange? = null, unreadCountAfter: Int? = null) {
|
||||||
val markedRead = markItemsReadInCurrentChat(cInfo, range)
|
val markedRead = markItemsReadInCurrentChat(cInfo, range)
|
||||||
// update preview
|
// update preview
|
||||||
val chatIdx = getChatIndex(cInfo.id)
|
val chatIdx = getChatIndex(cInfo.id)
|
||||||
@@ -221,7 +221,7 @@ class ChatModel(val controller: ChatController) {
|
|||||||
if (lastId != null) {
|
if (lastId != null) {
|
||||||
chats[chatIdx] = chat.copy(
|
chats[chatIdx] = chat.copy(
|
||||||
chatStats = chat.chatStats.copy(
|
chatStats = chat.chatStats.copy(
|
||||||
unreadCount = if (range != null) chat.chatStats.unreadCount - markedRead else 0,
|
unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0,
|
||||||
// Can't use minUnreadItemId currently since chat items can have unread items between read items
|
// Can't use minUnreadItemId currently since chat items can have unread items between read items
|
||||||
//minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1
|
//minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1279,8 +1279,8 @@ sealed class ChatPagination {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val INITIAL_COUNT = 100
|
const val INITIAL_COUNT = 100
|
||||||
const val PRELOAD_COUNT = 20
|
const val PRELOAD_COUNT = 100
|
||||||
const val UNTIL_PRELOAD_COUNT = 10
|
const val UNTIL_PRELOAD_COUNT = 50
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import android.content.res.Configuration
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.gestures.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.*
|
import androidx.compose.foundation.lazy.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.outlined.*
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.mapSaver
|
import androidx.compose.runtime.saveable.mapSaver
|
||||||
@@ -24,8 +26,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
@@ -57,9 +58,18 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
val chat = activeChat!!
|
val chat = activeChat!!
|
||||||
BackHandler { chatModel.chatId.value = null }
|
BackHandler { chatModel.chatId.value = null }
|
||||||
|
|
||||||
|
// We need to have real unreadCount value for displaying it inside top right button
|
||||||
|
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
||||||
|
val unreadCount = remember {
|
||||||
|
derivedStateOf {
|
||||||
|
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user,
|
user,
|
||||||
chat,
|
chat,
|
||||||
|
unreadCount,
|
||||||
composeState,
|
composeState,
|
||||||
composeView = {
|
composeView = {
|
||||||
if (chat.chatInfo.sendMsgEnabled) {
|
if (chat.chatInfo.sendMsgEnabled) {
|
||||||
@@ -167,8 +177,8 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
markRead = { range ->
|
markRead = { range, unreadCountAfter ->
|
||||||
chatModel.markChatItemsRead(chat.chatInfo, range)
|
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
|
||||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||||
withApi {
|
withApi {
|
||||||
chatModel.controller.apiChatRead(
|
chatModel.controller.apiChatRead(
|
||||||
@@ -186,6 +196,7 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
fun ChatLayout(
|
fun ChatLayout(
|
||||||
user: User,
|
user: User,
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
|
unreadCount: State<Int>,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
composeView: (@Composable () -> Unit),
|
composeView: (@Composable () -> Unit),
|
||||||
attachmentOption: MutableState<AttachmentOption?>,
|
attachmentOption: MutableState<AttachmentOption?>,
|
||||||
@@ -203,7 +214,7 @@ fun ChatLayout(
|
|||||||
startCall: (CallMediaType) -> Unit,
|
startCall: (CallMediaType) -> Unit,
|
||||||
acceptCall: (Contact) -> Unit,
|
acceptCall: (Contact) -> Unit,
|
||||||
addMembers: (GroupInfo) -> Unit,
|
addMembers: (GroupInfo) -> Unit,
|
||||||
markRead: (CC.ItemRange) -> Unit,
|
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -223,15 +234,19 @@ fun ChatLayout(
|
|||||||
sheetState = attachmentBottomSheetState,
|
sheetState = attachmentBottomSheetState,
|
||||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||||
) {
|
) {
|
||||||
|
val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers) },
|
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers) },
|
||||||
bottomBar = composeView,
|
bottomBar = composeView,
|
||||||
modifier = Modifier.navigationBarsWithImePadding()
|
modifier = Modifier.navigationBarsWithImePadding(),
|
||||||
|
floatingActionButton = floatingButton.value,
|
||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
BoxWithConstraints(Modifier.padding(contentPadding)) {
|
BoxWithConstraints(Modifier.padding(contentPadding)) {
|
||||||
ChatItemsList(user, chat, composeState, chatItems,
|
ChatItemsList(
|
||||||
|
user, chat, unreadCount, composeState, chatItems,
|
||||||
useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage,
|
useLinkPreviews, openDirectChat, loadPrevMessages, deleteMessage,
|
||||||
receiveFile, joinGroup, acceptCall, markRead)
|
receiveFile, joinGroup, acceptCall, markRead, floatingButton
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,6 +352,7 @@ val CIListStateSaver = run {
|
|||||||
fun BoxWithConstraintsScope.ChatItemsList(
|
fun BoxWithConstraintsScope.ChatItemsList(
|
||||||
user: User,
|
user: User,
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
|
unreadCount: State<Int>,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
chatItems: List<ChatItem>,
|
chatItems: List<ChatItem>,
|
||||||
useLinkPreviews: Boolean,
|
useLinkPreviews: Boolean,
|
||||||
@@ -346,28 +362,20 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
joinGroup: (Long) -> Unit,
|
joinGroup: (Long) -> Unit,
|
||||||
acceptCall: (Contact) -> Unit,
|
acceptCall: (Contact) -> Unit,
|
||||||
markRead: (CC.ItemRange) -> Unit,
|
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||||
|
floatingButton: MutableState<@Composable () -> Unit>
|
||||||
) {
|
) {
|
||||||
val firstVisibleOffset = -with(LocalDensity.current) { maxHeight.roundToPx() }
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
// Places first unread message at the top of a screen
|
|
||||||
val listState = rememberLazyListState(
|
|
||||||
initialFirstVisibleItemIndex = kotlin.math.max(kotlin.math.min(chatItems.size - 1, chatItems.count { it.isRcvNew }), 0),
|
|
||||||
initialFirstVisibleItemScrollOffset = firstVisibleOffset
|
|
||||||
)
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val cxt = LocalContext.current
|
val cxt = LocalContext.current
|
||||||
|
|
||||||
// Prevent scrolling to bottom on orientation change
|
// 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) }
|
var shouldAutoScroll by rememberSaveable { mutableStateOf(true) }
|
||||||
LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType) {
|
LaunchedEffect(chat.chatInfo.apiId, chat.chatInfo.chatType) {
|
||||||
val firstUnreadIndex = kotlin.math.max(kotlin.math.min(chatItems.size - 1, chatItems.count { it.isRcvNew }), 0)
|
if (shouldAutoScroll && listState.firstVisibleItemIndex != 0) {
|
||||||
if (shouldAutoScroll && listState.firstVisibleItemIndex != firstUnreadIndex) {
|
scope.launch { listState.scrollToItem(0) }
|
||||||
scope.launch {
|
|
||||||
// Places first unread message at the top of a screen after moving from group to direct chat
|
|
||||||
listState.scrollToItem(firstUnreadIndex, firstVisibleOffset)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Don't autoscroll next time until it will be needed
|
// Don't autoscroll next time until it will be needed
|
||||||
shouldAutoScroll = false
|
shouldAutoScroll = false
|
||||||
@@ -432,12 +440,89 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
LaunchedEffect(cItem.id) {
|
LaunchedEffect(cItem.id) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(750)
|
delay(750)
|
||||||
markRead(CC.ItemRange(cItem.id, cItem.id))
|
markRead(CC.ItemRange(cItem.id, cItem.id), null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, markRead, floatingButton, listState)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BoxWithConstraintsScope.FloatingButtons(
|
||||||
|
chatItems: List<ChatItem>,
|
||||||
|
unreadCount: State<Int>,
|
||||||
|
minUnreadItemId: Long,
|
||||||
|
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||||
|
floatingButton: MutableState<@Composable () -> Unit>,
|
||||||
|
listState: LazyListState
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val bottomUnreadCount by remember { derivedStateOf {
|
||||||
|
chatItems.subList(
|
||||||
|
chatItems.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex,
|
||||||
|
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 showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
|
||||||
|
floatingButton.value = bottomEndFloatingButton(
|
||||||
|
bottomUnreadCount,
|
||||||
|
showButtonWithCounter,
|
||||||
|
showButtonWithArrow,
|
||||||
|
onClickArrowDown = {
|
||||||
|
scope.launch { listState.animateScrollToItem(0) }
|
||||||
|
},
|
||||||
|
onClickCounter = {
|
||||||
|
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val fabSize = 56.dp
|
||||||
|
val topUnreadCount by remember {
|
||||||
|
derivedStateOf { unreadCount.value - bottomUnreadCount }
|
||||||
|
}
|
||||||
|
val showButtonWithCounter = topUnreadCount > 0
|
||||||
|
val height = with(LocalDensity.current) { maxHeight.toPx() }
|
||||||
|
var showDropDown by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
TopEndFloatingButton(
|
||||||
|
Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd),
|
||||||
|
topUnreadCount,
|
||||||
|
showButtonWithCounter,
|
||||||
|
onClick = { scope.launch { listState.animateScrollBy(height) } },
|
||||||
|
onLongClick = { showDropDown = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showDropDown,
|
||||||
|
onDismissRequest = { showDropDown = false },
|
||||||
|
Modifier.width(220.dp),
|
||||||
|
offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
onClick = {
|
||||||
|
markRead(
|
||||||
|
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
|
||||||
|
bottomUnreadCount
|
||||||
|
)
|
||||||
|
showDropDown = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
generalGetString(R.string.mark_read),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -480,6 +565,75 @@ fun MemberImage(member: GroupMember) {
|
|||||||
ProfileImage(38.dp, member.memberProfile.image)
|
ProfileImage(38.dp, member.memberProfile.image)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TopEndFloatingButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
unreadCount: Int,
|
||||||
|
showButtonWithCounter: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit
|
||||||
|
) = when {
|
||||||
|
showButtonWithCounter -> {
|
||||||
|
val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
|
||||||
|
FloatingActionButton(
|
||||||
|
{}, // no action here
|
||||||
|
modifier.size(48.dp),
|
||||||
|
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
unreadCountStr(unreadCount),
|
||||||
|
color = MaterialTheme.colors.primary,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bottomEndFloatingButton(
|
||||||
|
unreadCount: Int,
|
||||||
|
showButtonWithCounter: Boolean,
|
||||||
|
showButtonWithArrow: Boolean,
|
||||||
|
onClickArrowDown: () -> Unit,
|
||||||
|
onClickCounter: () -> Unit
|
||||||
|
): @Composable () -> Unit = when {
|
||||||
|
showButtonWithCounter -> {
|
||||||
|
{
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onClickCounter,
|
||||||
|
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
unreadCountStr(unreadCount),
|
||||||
|
color = MaterialTheme.colors.primary,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showButtonWithArrow -> {
|
||||||
|
{
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onClickArrowDown,
|
||||||
|
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.KeyboardArrowDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Preview(
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
@@ -507,6 +661,7 @@ fun PreviewChatLayout() {
|
|||||||
6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
6, CIDirection.DirectRcv(), Clock.System.now(), "hello"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user = User.sampleData,
|
user = User.sampleData,
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
@@ -514,6 +669,7 @@ fun PreviewChatLayout() {
|
|||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
chatStats = Chat.ChatStats()
|
chatStats = Chat.ChatStats()
|
||||||
),
|
),
|
||||||
|
unreadCount = unreadCount,
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
composeView = {},
|
composeView = {},
|
||||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||||
@@ -531,7 +687,7 @@ fun PreviewChatLayout() {
|
|||||||
startCall = {},
|
startCall = {},
|
||||||
acceptCall = { _ -> },
|
acceptCall = { _ -> },
|
||||||
addMembers = { _ -> },
|
addMembers = { _ -> },
|
||||||
markRead = { _ -> },
|
markRead = { _, _ -> },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,6 +714,7 @@ fun PreviewGroupChatLayout() {
|
|||||||
6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user = User.sampleData,
|
user = User.sampleData,
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
@@ -565,6 +722,7 @@ fun PreviewGroupChatLayout() {
|
|||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
chatStats = Chat.ChatStats()
|
chatStats = Chat.ChatStats()
|
||||||
),
|
),
|
||||||
|
unreadCount = unreadCount,
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
composeView = {},
|
composeView = {},
|
||||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||||
@@ -582,7 +740,7 @@ fun PreviewGroupChatLayout() {
|
|||||||
startCall = {},
|
startCall = {},
|
||||||
acceptCall = { _ -> },
|
acceptCall = { _ -> },
|
||||||
addMembers = { _ -> },
|
addMembers = { _ -> },
|
||||||
markRead = { _ -> },
|
markRead = { _, _ -> },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation),
|
unreadCountStr(n),
|
||||||
color = MaterialTheme.colors.onPrimary,
|
color = MaterialTheme.colors.onPrimary,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -163,6 +163,11 @@ fun ChatPreviewView(chat: Chat, stopped: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun unreadCountStr(n: Int): String {
|
||||||
|
return if (n < 1000) "$n" else "${n / 1000}" + stringResource(R.string.thousand_abbreviation)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatStatusImage(chat: Chat) {
|
fun ChatStatusImage(chat: Chat) {
|
||||||
val s = chat.serverInfo.networkStatus
|
val s = chat.serverInfo.networkStatus
|
||||||
|
|||||||
@@ -16,7 +16,13 @@
|
|||||||
|
|
||||||
package chat.simplex.app.views.helpers
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.gestures.forEachGesture
|
import androidx.compose.foundation.gestures.forEachGesture
|
||||||
|
import androidx.compose.foundation.interaction.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||||
@@ -31,18 +37,18 @@ import androidx.compose.ui.input.pointer.consumeAllChanges
|
|||||||
import androidx.compose.ui.input.pointer.consumeDownChange
|
import androidx.compose.ui.input.pointer.consumeDownChange
|
||||||
import androidx.compose.ui.input.pointer.isOutOfBounds
|
import androidx.compose.ui.input.pointer.isOutOfBounds
|
||||||
import androidx.compose.ui.input.pointer.positionChangeConsumed
|
import androidx.compose.ui.input.pointer.positionChangeConsumed
|
||||||
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.util.fastAll
|
import androidx.compose.ui.util.fastAll
|
||||||
import androidx.compose.ui.util.fastAny
|
import androidx.compose.ui.util.fastAny
|
||||||
import androidx.compose.ui.util.fastForEach
|
import androidx.compose.ui.util.fastForEach
|
||||||
import kotlinx.coroutines.coroutineScope
|
import chat.simplex.app.TAG
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
|
* See original code here: [androidx.compose.foundation.gestures.detectTapGestures]
|
||||||
* */
|
* */
|
||||||
|
|
||||||
interface PressGestureScope: Density {
|
interface PressGestureScope: Density {
|
||||||
suspend fun tryAwaitRelease(): Boolean
|
suspend fun tryAwaitRelease(): Boolean
|
||||||
}
|
}
|
||||||
@@ -67,7 +73,6 @@ suspend fun PointerInputScope.detectGesture(
|
|||||||
if (onPress !== NoPressGesture) launch {
|
if (onPress !== NoPressGesture) launch {
|
||||||
pressScope.onPress(down.position)
|
pressScope.onPress(down.position)
|
||||||
}
|
}
|
||||||
|
|
||||||
val longPressTimeout = onLongPress?.let {
|
val longPressTimeout = onLongPress?.let {
|
||||||
viewConfiguration.longPressTimeoutMillis
|
viewConfiguration.longPressTimeoutMillis
|
||||||
} ?: (Long.MAX_VALUE / 2)
|
} ?: (Long.MAX_VALUE / 2)
|
||||||
@@ -81,7 +86,6 @@ suspend fun PointerInputScope.detectGesture(
|
|||||||
} else {
|
} else {
|
||||||
if (shouldConsume)
|
if (shouldConsume)
|
||||||
upOrCancel.consumeDownChange()
|
upOrCancel.consumeDownChange()
|
||||||
|
|
||||||
// If onLongPress event is needed, cancel short press event
|
// If onLongPress event is needed, cancel short press event
|
||||||
if (onLongPress != null)
|
if (onLongPress != null)
|
||||||
pressScope.cancel()
|
pressScope.cancel()
|
||||||
@@ -138,7 +142,6 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange
|
|||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
|
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
|
||||||
if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
|
if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
|
||||||
return null
|
return null
|
||||||
@@ -176,3 +179,49 @@ private class PressGestureScopeImpl(
|
|||||||
return isCanceled
|
return isCanceled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures click events and calls [onLongClick] or [onClick] when such even happens. Otherwise, does nothing.
|
||||||
|
* Apply [MutableInteractionSource] to any element that allows to pass it in (for example, in [Modifier.clickable]).
|
||||||
|
* Works in situations when using [Modifier.combinedClickable] doesn't work because external element overrides [Modifier.clickable]
|
||||||
|
* */
|
||||||
|
@Composable
|
||||||
|
fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit): MutableInteractionSource {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||||
|
var topLevelInteraction: Interaction? by remember { mutableStateOf(null) }
|
||||||
|
LaunchedEffect(interactionSource) {
|
||||||
|
interactionSource.interactions.collect { interaction ->
|
||||||
|
topLevelInteraction = interaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(topLevelInteraction is PressInteraction.Press) {
|
||||||
|
if (topLevelInteraction !is PressInteraction.Press) return@LaunchedEffect
|
||||||
|
try {
|
||||||
|
withTimeout(longPressTimeoutMillis) {
|
||||||
|
while (isActive) {
|
||||||
|
delay(10)
|
||||||
|
when (topLevelInteraction) {
|
||||||
|
is PressInteraction.Press -> {}
|
||||||
|
is PressInteraction.Release -> {
|
||||||
|
onClick(); break
|
||||||
|
}
|
||||||
|
is PressInteraction.Cancel -> break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: TimeoutCancellationException) {
|
||||||
|
// Long click happened
|
||||||
|
onLongClick()
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
// Canceled coroutine + PressInteraction.Release == short click
|
||||||
|
if (topLevelInteraction is PressInteraction.Release)
|
||||||
|
onClick()
|
||||||
|
Log.e(TAG, ex.stackTraceToString())
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
// Should never be called
|
||||||
|
Log.e(TAG, ex.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return interactionSource
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user