feat: catch up screen (unfinished)

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-11-29 10:29:44 +01:00
parent 71c8b4581a
commit dc8f8d9815
7 changed files with 497 additions and 100 deletions

View File

@ -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) }

View File

@ -94,6 +94,46 @@ class Unreads {
}
}
fun getAllUnreads(): List<ChannelUnread> {
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

View File

@ -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<CatchUpCard>(initialCapacity = 3)
private var dataSource: Iterator<ChannelUnread> = emptyList<ChannelUnread>().iterator()
var initComplete = false
private set
private val _cards = mutableStateOf<List<CatchUpCard>>(emptyList())
val cards: State<List<CatchUpCard>> = _cards
fun initWith(unreads: List<ChannelUnread>) {
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}")
}
}
}
}
}
}
}
}
}

View File

@ -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<Int?>(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()

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM200,760L760,760Q760,760 760,760Q760,760 760,760L760,640L640,640Q610,678 568.5,699Q527,720 480,720Q433,720 391.5,699Q350,678 320,640L200,640L200,760Q200,760 200,760Q200,760 200,760ZM480,640Q518,640 549,618Q580,596 592,560L760,560L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,560L368,560Q380,596 411,618Q442,640 480,640ZM200,760Q200,760 200,760Q200,760 200,760L200,760L320,760Q350,760 391.5,760Q433,760 480,760Q527,760 568.5,760Q610,760 640,760L760,760L760,760Q760,760 760,760Q760,760 760,760L200,760Z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM200,760L760,760Q760,760 760,760Q760,760 760,760L760,640L640,640Q610,678 568.5,699Q527,720 480,720Q433,720 391.5,699Q350,678 320,640L200,640L200,760Q200,760 200,760Q200,760 200,760ZM480,640Q518,640 549,618Q580,596 592,560L760,560L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,560L368,560Q380,596 411,618Q442,640 480,640ZM480,560L320,400L376,342L440,406L440,240L520,240L520,406L584,342L640,400L480,560ZM200,760Q200,760 200,760Q200,760 200,760L200,760L320,760Q350,760 391.5,760Q433,760 480,760Q527,760 568.5,760Q610,760 640,760L760,760L760,760Q760,760 760,760Q760,760 760,760L200,760Z"/>
</vector>

View File

@ -156,6 +156,13 @@
<string name="overview_screen_changelog_description">Stoat never sleeps, take a look to see what\'s been in the works</string>
<string name="overview_screen_feedback">Provide Feedback</string>
<string name="overview_screen_feedback_description">Got something to say? We\'re all ears</string>
<string name="overview_screen_catch_up">Catch Up</string>
<string name="overview_screen_catch_up_description">See what you\'ve missed while you were away</string>
<string name="overview_screen_catch_up_none">All caught up</string>
<string name="overview_screen_catch_up_none_description">You\'ve seen all new messages since your last visit.</string>
<string name="overview_screen_catch_up_amount_badge_loading"></string>
<string name="catch_up">Catch Up</string>
<string name="user_card_tap_to_copy">Tap to copy</string>
<string name="user_card_error_sharing">An error occurred while sharing your user card.</string>