feat: add some basic interactive animations (#46)

Reviewed-on: #46
This commit was merged in pull request #46.
This commit is contained in:
2026-07-17 09:27:39 +00:00
parent 47196dbea4
commit 9defad522c
6 changed files with 60 additions and 43 deletions

View File

@@ -4,7 +4,6 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -134,10 +133,6 @@ fun App(
else -> expressiveLightColorScheme() else -> expressiveLightColorScheme()
} }
BackHandler(enabled = backStack.size > 1) {
navigator.goBack()
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { launch {
accountViewModel.errorEvents.collect { message -> accountViewModel.errorEvents.collect { message ->

View File

@@ -7,6 +7,7 @@ import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -86,6 +87,7 @@ import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_qr
import coop.composeapp.generated.resources.ic_request import coop.composeapp.generated.resources.ic_request
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
@@ -249,7 +251,9 @@ fun HomeScreen(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
if (!isNotificationEnabled && !isBannerDismissed) { AnimatedVisibility(
visible = !isNotificationEnabled && !isBannerDismissed,
) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -372,10 +376,14 @@ fun HomeScreen(
} }
items(ongoing, key = { it.id }) { room -> items(ongoing, key = { it.id }) { room ->
ChatRoom( Row(
room = room, modifier = Modifier.animateItem()
onClick = { navigator.navigate(Screen.Chat(room.id)) } ) {
) ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
}
} }
} }
} }
@@ -620,13 +628,16 @@ fun NewRequests(requests: List<Room>) {
val firstRoom = requests.getOrNull(0) val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1) val secondRoom = requests.getOrNull(1)
val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache) val firstRoomState by remember(firstRoom?.id) {
.collectAsStateWithLifecycle(RoomUiState()) firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache) }.collectAsStateWithLifecycle(RoomUiState())
.collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by remember(secondRoom?.id) {
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
}.collectAsStateWithLifecycle(RoomUiState())
val supportingText = when { val supportingText = when {
total == 1 -> { total == 1 && firstRoom != null -> {
val message = firstRoom.lastMessage ?: "" val message = firstRoom.lastMessage ?: ""
"${firstRoomState.name}: $message" "${firstRoomState.name}: $message"
} }
@@ -695,7 +706,8 @@ fun NewRequests(requests: List<Room>) {
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val profileCache = LocalProfileCache.current val profileCache = LocalProfileCache.current
val roomState by room.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState()) val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
.collectAsStateWithLifecycle(RoomUiState())
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),

View File

@@ -185,8 +185,8 @@ fun NewChatScreen(
) )
}, },
text = { Text("Next") }, text = { Text("Next") },
containerColor = MaterialTheme.colorScheme.tertiary, containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiary, contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
) )
} }
} }

View File

@@ -1,10 +1,5 @@
package su.reya.coop.screens.chat package su.reya.coop.screens.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -177,11 +172,7 @@ fun ChatMessage(
) )
} }
} }
AnimatedVisibility( if (isMessageClicked) {
visible = isMessageClicked,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Text( Text(
text = model.timestamp, text = model.timestamp,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,

View File

@@ -146,8 +146,9 @@ fun ChatScreen(
val groupedMessages = val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } } remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey) val roomState by remember(id, currentUser?.publicKey) {
.collectAsStateWithLifecycle(RoomUiState()) (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
}.collectAsStateWithLifecycle(RoomUiState())
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) } var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
@@ -295,7 +296,9 @@ fun ChatScreen(
} }
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.animateItem(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
replyPreview?.let { ReplyPreview(it, model.isMine) } replyPreview?.let { ReplyPreview(it, model.isMine) }
@@ -380,8 +383,10 @@ fun ChatScreen(
} }
else -> { else -> {
replyingTo?.let { AnimatedVisibility(visible = replyingTo != null) {
ReplyBox(it) { replyingTo = null } replyingTo?.let {
ReplyBox(it) { replyingTo = null }
}
} }
ChatInput( ChatInput(
value = text, value = text,
@@ -420,7 +425,6 @@ fun ChatScreen(
} }
} }
) )
AnimatedVisibility( AnimatedVisibility(
visible = selectedMessage != null, visible = selectedMessage != null,
enter = fadeIn(), enter = fadeIn(),

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -21,6 +22,7 @@ import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Tag import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import su.reya.coop.roomId import su.reya.coop.roomId
import su.reya.coop.viewmodel.ErrorHost import su.reya.coop.viewmodel.ErrorHost
@@ -38,11 +40,7 @@ class ChatRepository(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() { ) : ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState()) private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn( val state = _state.asStateFlow()
scope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val _newEvents = MutableSharedFlow<UnsignedEvent>( private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0, replay = 0,
@@ -134,10 +132,19 @@ class ChatRepository(
fun refreshChatRooms() { fun refreshChatRooms() {
scope.launch(defaultDispatcher) { scope.launch(defaultDispatcher) {
try { try {
val rooms = nostr.messages.getChatRooms() ?: emptySet() val dbRooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState -> _state.update { currentState ->
val newMap = currentState.rooms.toMutableMap() val newMap = currentState.rooms.toMutableMap()
rooms.forEach { room -> newMap[room.id] = room } dbRooms.forEach { dbRoom ->
val existing = newMap[dbRoom.id]
// Only update if the database version is newer or equal
if (existing == null || dbRoom.createdAt.asSecs() >= existing.createdAt.asSecs()) {
// Preserve Ongoing kind if already marked as such in memory
val mergedKind =
if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind
newMap[dbRoom.id] = dbRoom.copy(kind = mergedKind)
}
}
currentState.copy(rooms = newMap) currentState.copy(rooms = newMap)
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
@@ -223,16 +230,24 @@ class ChatRepository(
val rooms = currentState.rooms.toMutableMap() val rooms = currentState.rooms.toMutableMap()
val existingRoom = rooms[roomId] val existingRoom = rooms[roomId]
val isFromMe = event.author() == currentUser
val newKind =
if (isFromMe) RoomKind.Ongoing else (existingRoom?.kind ?: RoomKind.Request)
if (existingRoom == null) { if (existingRoom == null) {
// New room discovery // New room discovery
val newRoom = Room.new(event, currentUser, roomId) val newRoom = Room.new(event, currentUser, roomId).copy(kind = newKind)
rooms[newRoom.id] = newRoom rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) { } else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Only update preview if message is newer (handles sync/late arrivals) // Only update preview if message is newer (handles sync/late arrivals)
rooms[roomId] = existingRoom.copy( rooms[roomId] = existingRoom.copy(
lastMessage = event.content(), lastMessage = event.content(),
createdAt = event.createdAt() createdAt = event.createdAt(),
kind = newKind
) )
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
// Even if it's an older message, if it's from me, the room is ongoing
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
} else { } else {
// Don't update the room list state for older messages // Don't update the room list state for older messages
return@update currentState return@update currentState