From dc8f8d9815d791f669fce946adcb3e4829f33bec Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 29 Nov 2025 10:29:44 +0100 Subject: [PATCH] feat: catch up screen (unfinished) Signed-off-by: Infi --- .../chat/stoat/activities/MainActivity.kt | 3 + .../java/chat/stoat/api/unreads/Unreads.kt | 40 +++ .../screens/chat/standalone/CatchUpScreen.kt | 234 ++++++++++++++ .../screens/chat/views/OverviewScreen.kt | 293 ++++++++++++------ app/src/main/res/drawable/icn_inbox_24dp.xml | 10 + .../res/drawable/icn_move_to_inbox_24dp.xml | 10 + app/src/main/res/values/strings.xml | 7 + 7 files changed, 497 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/chat/stoat/screens/chat/standalone/CatchUpScreen.kt create mode 100644 app/src/main/res/drawable/icn_inbox_24dp.xml create mode 100644 app/src/main/res/drawable/icn_move_to_inbox_24dp.xml diff --git a/app/src/main/java/chat/stoat/activities/MainActivity.kt b/app/src/main/java/chat/stoat/activities/MainActivity.kt index b150a271..bb1367ce 100644 --- a/app/src/main/java/chat/stoat/activities/MainActivity.kt +++ b/app/src/main/java/chat/stoat/activities/MainActivity.kt @@ -97,6 +97,7 @@ import chat.stoat.screens.DefaultDestinationScreen import chat.stoat.screens.about.AboutScreen import chat.stoat.screens.about.AttributionScreen import chat.stoat.screens.chat.ChatRouterScreen +import chat.stoat.screens.chat.standalone.CatchUpScreen import chat.stoat.screens.chat.views.channel.ChannelScreen import chat.stoat.screens.create.CreateGroupScreen import chat.stoat.screens.labs.LabsRootScreen @@ -703,6 +704,8 @@ fun AppEntrypoint( ) } + composable("catchup") { CatchUpScreen(navController) } + composable("create/group") { CreateGroupScreen(navController) } composable("discover") { DiscoverScreen(navController) } diff --git a/app/src/main/java/chat/stoat/api/unreads/Unreads.kt b/app/src/main/java/chat/stoat/api/unreads/Unreads.kt index 5f336221..96963b99 100644 --- a/app/src/main/java/chat/stoat/api/unreads/Unreads.kt +++ b/app/src/main/java/chat/stoat/api/unreads/Unreads.kt @@ -94,6 +94,46 @@ class Unreads { } } + fun getAllUnreads(): List { + if (!hasLoaded.value) return emptyList() + return channels.values.toList() + } + + /** + * Returns true if there are any unreads in any of the channels that are not muted. + * **SLOW:** Run in a background coroutine. + */ + fun hasAnyUnreads(): Boolean { + if (!hasLoaded.value) return false + + for ((channelId, unread) in StoatAPI.channelCache) { + if (channelId !in channels) continue + if (NotificationSettingsProvider.isChannelMuted(channelId, unread.server)) continue + if (hasUnread(channelId, unread.lastMessageID ?: "", unread.server)) { + return true + } + } + return false + } + + /** + * Returns the count of channels with unreads that are not muted. + * **SLOW:** Run in a background coroutine. + */ + fun countChannelsWithUnreads(): Int? { + if (!hasLoaded.value) return null + + var count = 0 + for ((channelId, unread) in StoatAPI.channelCache) { + if (channelId !in channels) continue + if (NotificationSettingsProvider.isChannelMuted(channelId, unread.server)) continue + if (hasUnread(channelId, unread.lastMessageID ?: "", unread.server)) { + count++ + } + } + return count + } + fun clear() { channels.clear() hasLoaded.value = false diff --git a/app/src/main/java/chat/stoat/screens/chat/standalone/CatchUpScreen.kt b/app/src/main/java/chat/stoat/screens/chat/standalone/CatchUpScreen.kt new file mode 100644 index 00000000..16c73ba7 --- /dev/null +++ b/app/src/main/java/chat/stoat/screens/chat/standalone/CatchUpScreen.kt @@ -0,0 +1,234 @@ +package chat.stoat.screens.chat.standalone + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import chat.stoat.R +import chat.stoat.api.StoatAPI +import chat.stoat.api.schemas.ChannelUnread +import logcat.LogPriority +import logcat.logcat + +sealed class CatchUpCard { + data class UnreadMessageInChannel( + val channelId: String, + val lastReadMessageId: String, + val newestMessageId: String + ) : CatchUpCard() +} + +class CatchUpScreenViewModel : ViewModel() { + private val deck = ArrayDeque(initialCapacity = 3) + private var dataSource: Iterator = emptyList().iterator() + var initComplete = false + private set + + private val _cards = mutableStateOf>(emptyList()) + val cards: State> = _cards + + fun initWith(unreads: List) { + dataSource = unreads.iterator() + + repeat(3) { + dealNewCard() + } + + initComplete = true + } + + fun dealNewCard() { + if (dataSource.hasNext()) { + val unread = dataSource.next() + unread.last_id?.let { lastId -> + deck.addLast( + CatchUpCard.UnreadMessageInChannel( + channelId = unread.id, + lastReadMessageId = lastId, + newestMessageId = lastId // TODO: Replace with actual newest message ID + ) + ) + deckUpdated() + } + } else { + logcat(LogPriority.WARN) { "No more unreads to deal!" } + } + } + + fun swipedCard() { + if (deck.isNotEmpty()) { + deck.removeFirst() + deckUpdated() + } + dealNewCard() + } + + fun deckUpdated() { + _cards.value = deck.toList() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CatchUpScreen(navController: NavController, viewModel: CatchUpScreenViewModel = viewModel()) { + LaunchedEffect(Unit) { + viewModel.initWith(StoatAPI.unreads.getAllUnreads()) + } + + val primaryContainer = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f) + val colourKeep = remember { Color(0xFFF84848).copy(alpha = 0.5f) } + val colourRead = remember { Color(0xFF3ABF7E).copy(alpha = 0.5f) } + + var currentColour by remember { mutableStateOf(primaryContainer) } + val gradientColour by animateColorAsState( + targetValue = currentColour, + animationSpec = tween(durationMillis = 500) + ) + + val cards by viewModel.cards + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.catch_up), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = { + navController.popBackStack() + }) { + Icon( + painter = painterResource(R.drawable.icn_arrow_back_24dp), + contentDescription = stringResource(id = R.string.back) + ) + } + } + ) + } + ) { pv -> + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .background( + Brush.linearGradient( + 0.2f to MaterialTheme.colorScheme.background, + 1.0f to gradientColour, + end = Offset.Infinite.copy(x = 0f) + ) + ) + .fillMaxSize() + ) + + Box( + Modifier + .padding(pv) + .imePadding() + ) { + for (card in cards.reversed()) { + when (card) { + is CatchUpCard.UnreadMessageInChannel -> { + val state = rememberSwipeToDismissBoxState() + + LaunchedEffect(state.currentValue) { + if (state.currentValue == SwipeToDismissBoxValue.StartToEnd) { + // Start to end is mark read + viewModel.swipedCard() + state.reset() + } else if (state.currentValue == SwipeToDismissBoxValue.EndToStart) { + // End to start is skip + viewModel.swipedCard() + state.reset() + } + } + + LaunchedEffect(state.dismissDirection) { + when (state.dismissDirection) { + SwipeToDismissBoxValue.StartToEnd -> { + currentColour = colourRead + } + + SwipeToDismissBoxValue.EndToStart -> { + currentColour = colourKeep + } + + SwipeToDismissBoxValue.Settled -> { + currentColour = primaryContainer + } + } + } + + SwipeToDismissBox( + state = state, + modifier = Modifier + .padding(16.dp) + .fillMaxSize(), + backgroundContent = { + if (state.dismissDirection == SwipeToDismissBoxValue.StartToEnd) { + Text( + "Mark as read", + Modifier + .fillMaxWidth() + .background(Color.Green) + ) + } else if (state.dismissDirection == SwipeToDismissBoxValue.EndToStart) { + Text( + "Keep unread", + textAlign = TextAlign.Right, + modifier = Modifier + .fillMaxWidth() + .background(Color.Red) + ) + } + } + ) { + Column(Modifier.background(Color.Black)) { + Text("Channel ID: ${card.channelId}") + Text("Last Read Message ID: ${card.lastReadMessageId}") + Text("Newest Message ID: ${card.newestMessageId}") + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/stoat/screens/chat/views/OverviewScreen.kt b/app/src/main/java/chat/stoat/screens/chat/views/OverviewScreen.kt index ce6199dc..26366457 100644 --- a/app/src/main/java/chat/stoat/screens/chat/views/OverviewScreen.kt +++ b/app/src/main/java/chat/stoat/screens/chat/views/OverviewScreen.kt @@ -4,6 +4,7 @@ import android.util.Log import android.widget.Toast import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -22,6 +23,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.Badge import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -45,7 +48,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -107,6 +113,26 @@ fun OverviewScreen( } } + var hasUnreads by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + hasUnreads = StoatAPI.unreads.hasAnyUnreads() + } + + var countUnreads by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(hasUnreads) { + countUnreads = if (hasUnreads) { + StoatAPI.unreads.countChannelsWithUnreads() + } else { + 0 + } + } + + val lazyStaggeredGridState = rememberLazyStaggeredGridState() + val scrollabilityIndicatorOpacity by animateFloatAsState( + if (!lazyStaggeredGridState.canScrollBackward && lazyStaggeredGridState.canScrollForward) 1.0f else 0.0f, + label = "scrollabilityIndicatorOpacity" + ) + Scaffold( topBar = { Column { @@ -197,109 +223,175 @@ fun OverviewScreen( visible = !isLoading, enter = fadeIn(), ) { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Adaptive(300.dp), - verticalItemSpacing = 16.dp, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - item(key = "settings") { - OverviewScreenLink( - onClick = { - navController.navigate("settings") - }, - backgroundColour = MaterialTheme.colorScheme.primaryContainer, - foregroundColour = MaterialTheme.colorScheme.onPrimaryContainer, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - painter = painterResource(R.drawable.icn_settings_24dp), - contentDescription = null, - modifier = Modifier.size(22.dp) + Box { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(300.dp), + verticalItemSpacing = 16.dp, + horizontalArrangement = Arrangement.spacedBy(16.dp), + state = lazyStaggeredGridState + ) { + item(key = "unreads") { + OverviewScreenLink( + onClick = { + navController.navigate("catchup") + }, + clickable = hasUnreads, + backgroundColour = if (hasUnreads) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant, + foregroundColour = if (hasUnreads) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = if (hasUnreads) painterResource(R.drawable.icn_move_to_inbox_24dp) else painterResource( + R.drawable.icn_inbox_24dp + ), + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + Text( + if (hasUnreads) stringResource(R.string.overview_screen_catch_up) else stringResource( + R.string.overview_screen_catch_up_none + ) + ) + + if (hasUnreads) { + Spacer(Modifier.weight(1f)) + Badge { + Text( + countUnreads?.toString() + ?: stringResource(R.string.overview_screen_catch_up_amount_badge_loading) + ) + } + } + } + }, + body = { + Text( + if (hasUnreads) stringResource(R.string.overview_screen_catch_up_description) else stringResource( + R.string.overview_screen_catch_up_none_description + ) ) - Text(stringResource(R.string.overview_screen_settings)) } - }, - body = { Text(stringResource(R.string.overview_screen_settings_description)) } - ) - } - item(key = "shareProfile") { - OverviewScreenLink( - onClick = { - showUserCardSheet = true - }, - backgroundColour = MaterialTheme.colorScheme.tertiaryContainer, - foregroundColour = MaterialTheme.colorScheme.onTertiaryContainer, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - painter = painterResource(R.drawable.icn_ios_share_24dp), - contentDescription = null, - modifier = Modifier.size(22.dp) - ) - Text(stringResource(R.string.overview_screen_share_profile)) - } - }, - body = { Text(stringResource(R.string.overview_screen_share_profile_description)) } - ) + ) + } + + item(key = "settings") { + OverviewScreenLink( + onClick = { + navController.navigate("settings") + }, + backgroundColour = MaterialTheme.colorScheme.primaryContainer, + foregroundColour = MaterialTheme.colorScheme.onPrimaryContainer, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.icn_settings_24dp), + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + Text(stringResource(R.string.overview_screen_settings)) + } + }, + body = { Text(stringResource(R.string.overview_screen_settings_description)) } + ) + } + item(key = "shareProfile") { + OverviewScreenLink( + onClick = { + showUserCardSheet = true + }, + backgroundColour = MaterialTheme.colorScheme.tertiaryContainer, + foregroundColour = MaterialTheme.colorScheme.onTertiaryContainer, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.icn_ios_share_24dp), + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + Text(stringResource(R.string.overview_screen_share_profile)) + } + }, + body = { Text(stringResource(R.string.overview_screen_share_profile_description)) } + ) + } + + item(key = "changelog") { + OverviewScreenLink( + onClick = { + navController.navigate("settings/changelogs") + }, + backgroundColour = MaterialTheme.colorScheme.errorContainer, + foregroundColour = MaterialTheme.colorScheme.onErrorContainer, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.icn_wand_shine_24dp), + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + Text(stringResource(R.string.overview_screen_changelog)) + } + }, + body = { Text(stringResource(R.string.overview_screen_changelog_description)) } + ) + } + item(key = "feedback") { + OverviewScreenLink( + onClick = { + Toast.makeText( + context, + context.getString(R.string.comingsoon_toast), + Toast.LENGTH_SHORT + ).show() + // navController.navigate("feedback") + }, + backgroundColour = MaterialTheme.colorScheme.primary, + foregroundColour = MaterialTheme.colorScheme.onPrimary, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.icn_star_shine_24dp), + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + Text(stringResource(R.string.overview_screen_feedback)) + } + }, + body = { Text(stringResource(R.string.overview_screen_feedback_description)) } + ) + } } - item(key = "changelog") { - OverviewScreenLink( - onClick = { - navController.navigate("settings/changelogs") - }, - backgroundColour = MaterialTheme.colorScheme.errorContainer, - foregroundColour = MaterialTheme.colorScheme.onErrorContainer, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - painter = painterResource(R.drawable.icn_wand_shine_24dp), - contentDescription = null, - modifier = Modifier.size(22.dp) - ) - Text(stringResource(R.string.overview_screen_changelog)) - } - }, - body = { Text(stringResource(R.string.overview_screen_changelog_description)) } - ) - } - item(key = "feedback") { - OverviewScreenLink( - onClick = { - Toast.makeText( - context, - context.getString(R.string.comingsoon_toast), - Toast.LENGTH_SHORT - ).show() - // navController.navigate("feedback") - }, - backgroundColour = MaterialTheme.colorScheme.primary, - foregroundColour = MaterialTheme.colorScheme.onPrimary, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - painter = painterResource(R.drawable.icn_star_shine_24dp), - contentDescription = null, - modifier = Modifier.size(22.dp) - ) - Text(stringResource(R.string.overview_screen_feedback)) - } - }, - body = { Text(stringResource(R.string.overview_screen_feedback_description)) } - ) - } + Box( + Modifier + .alpha(scrollabilityIndicatorOpacity) + .background( + Brush.linearGradient( + 0.0f to MaterialTheme.colorScheme.background.copy(alpha = 0.0f), + 0.5f to MaterialTheme.colorScheme.background.copy(alpha = 0.3f), + 1.0f to MaterialTheme.colorScheme.background, + end = Offset.Infinite.copy(x = .0f) + ) + ) + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(60.dp) + ) } } } @@ -311,13 +403,14 @@ fun OverviewScreenLink( onClick: () -> Unit, backgroundColour: Color, foregroundColour: Color, + clickable: Boolean = true, title: @Composable () -> Unit, body: @Composable () -> Unit, ) { Box( modifier = Modifier .clip(MaterialTheme.shapes.extraLarge) - .clickable(onClick = onClick) + .then(if (clickable) Modifier.clickable(onClick = onClick) else Modifier) .background(backgroundColour) .padding(vertical = 32.dp) .fillMaxWidth() diff --git a/app/src/main/res/drawable/icn_inbox_24dp.xml b/app/src/main/res/drawable/icn_inbox_24dp.xml new file mode 100644 index 00000000..32c0dd22 --- /dev/null +++ b/app/src/main/res/drawable/icn_inbox_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/icn_move_to_inbox_24dp.xml b/app/src/main/res/drawable/icn_move_to_inbox_24dp.xml new file mode 100644 index 00000000..66ac4aee --- /dev/null +++ b/app/src/main/res/drawable/icn_move_to_inbox_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bbc6254a..01566215 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -156,6 +156,13 @@ Stoat never sleeps, take a look to see what\'s been in the works Provide Feedback Got something to say? We\'re all ears + Catch Up + See what you\'ve missed while you were away + All caught up + You\'ve seen all new messages since your last visit. + + + Catch Up Tap to copy An error occurred while sharing your user card.