feat(messaging): ungelesene Nachrichten als Badges anzeigen (#110)

- DB-Migration 7→8: is_read-Spalte in messages (default 1 für bestehende Rows)
- DAO: getUnreadCountsBySender, getTotalUnreadCount, markConversationAsRead
- Repository: totalUnreadCount Flow + getUnreadCountsBySender() + markConversationAsRead()
- ChatViewModel: markConversationAsRead beim Öffnen/Empfangen
- UserListViewModel: unreadCounts StateFlow
- UserListScreen: rote Badge-Anzeige pro Chat
- MainViewModel: totalUnreadCount StateFlow
- MainScreen: BadgedBox am Nachrichten-Icon in Bottom Nav
- NotificationHelper: updateBadgeCount() für Launcher-Badge
- Tests: 3 neue Fälle, FakeDao+FakeRepo aktualisiert (328 Tests grün)
This commit is contained in:
Jens Reinemann 2026-05-18 18:28:49 +02:00
parent 06fa017c04
commit 512829dd49
14 changed files with 208 additions and 22 deletions

View file

@ -19,11 +19,12 @@ import de.bollwerk.app.data.db.entity.SettingsEntity
@Database( @Database(
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class], entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class],
version = 7, version = 8,
exportSchema = true, exportSchema = true,
autoMigrations = [ autoMigrations = [
AutoMigration(from = 5, to = 6), AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7) AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8)
] ]
) )
@TypeConverters(LocalDateConverter::class) @TypeConverters(LocalDateConverter::class)

View file

@ -1,5 +1,6 @@
package de.bollwerk.app.data.db.dao package de.bollwerk.app.data.db.dao
import androidx.room.ColumnInfo
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
@ -8,6 +9,11 @@ import androidx.room.Upsert
import de.bollwerk.app.data.db.entity.MessageEntity import de.bollwerk.app.data.db.entity.MessageEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
internal data class UnreadCountBySender(
@ColumnInfo(name = "sender_id") val senderId: String,
@ColumnInfo(name = "count") val count: Int
)
@Dao @Dao
internal interface MessageDao { internal interface MessageDao {
@ -30,4 +36,13 @@ internal interface MessageDao {
@Query("UPDATE messages SET is_pending = 0 WHERE id = :id") @Query("UPDATE messages SET is_pending = 0 WHERE id = :id")
suspend fun markDelivered(id: String) suspend fun markDelivered(id: String)
@Query("SELECT sender_id, COUNT(*) as count FROM messages WHERE receiver_id = :myId AND is_read = 0 GROUP BY sender_id")
fun getUnreadCountsBySender(myId: String): Flow<List<UnreadCountBySender>>
@Query("SELECT COUNT(*) FROM messages WHERE receiver_id = :myId AND is_read = 0")
fun getTotalUnreadCount(myId: String): Flow<Int>
@Query("UPDATE messages SET is_read = 1 WHERE receiver_id = :myId AND sender_id = :senderId")
suspend fun markConversationAsRead(myId: String, senderId: String)
} }

View file

@ -12,5 +12,8 @@ internal data class MessageEntity(
@ColumnInfo(name = "receiver_id") val receiverId: String, @ColumnInfo(name = "receiver_id") val receiverId: String,
@ColumnInfo(name = "body") val body: String, @ColumnInfo(name = "body") val body: String,
@ColumnInfo(name = "sent_at") val sentAt: Long, @ColumnInfo(name = "sent_at") val sentAt: Long,
@ColumnInfo(name = "is_pending") val isPending: Boolean @ColumnInfo(name = "is_pending") val isPending: Boolean,
// defaultValue "1" = existing rows after migration are treated as already read;
// Kotlin default false = new objects start as unread (caller sets explicitly)
@ColumnInfo(name = "is_read", defaultValue = "1") val isRead: Boolean = false
) )

View file

@ -28,7 +28,11 @@ import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -50,6 +54,7 @@ private data class SendMessageRequest(
@Serializable @Serializable
private data class PublicKeyResponse(val publicKey: String) private data class PublicKeyResponse(val publicKey: String)
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton @Singleton
internal class MessageRepositoryImpl @Inject constructor( internal class MessageRepositoryImpl @Inject constructor(
private val dao: MessageDao, private val dao: MessageDao,
@ -84,7 +89,8 @@ internal class MessageRepositoryImpl @Inject constructor(
receiverId = msg.receiverId, receiverId = msg.receiverId,
body = decryptedBody, body = decryptedBody,
sentAt = msg.sentAt, sentAt = msg.sentAt,
isPending = false isPending = false,
isRead = false
) )
) )
notificationHelper.showNewMessageNotification( notificationHelper.showNewMessageNotification(
@ -100,6 +106,9 @@ internal class MessageRepositoryImpl @Inject constructor(
} }
} }
} }
scope.launch {
totalUnreadCount.collect { count -> notificationHelper.updateBadgeCount(count) }
}
} }
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> = override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> =
@ -126,7 +135,8 @@ internal class MessageRepositoryImpl @Inject constructor(
receiverId = recipientId, receiverId = recipientId,
body = body, body = body,
sentAt = sentAt, sentAt = sentAt,
isPending = true isPending = true,
isRead = true
) )
) )
@ -143,7 +153,8 @@ internal class MessageRepositoryImpl @Inject constructor(
receiverId = myId, receiverId = myId,
body = systemMessage, body = systemMessage,
sentAt = System.currentTimeMillis(), sentAt = System.currentTimeMillis(),
isPending = false isPending = false,
isRead = true
) )
) )
} }
@ -212,7 +223,8 @@ internal class MessageRepositoryImpl @Inject constructor(
receiverId = msg.senderId, receiverId = msg.senderId,
body = systemMessage, body = systemMessage,
sentAt = System.currentTimeMillis(), sentAt = System.currentTimeMillis(),
isPending = false isPending = false,
isRead = true
) )
) )
} }
@ -313,6 +325,27 @@ internal class MessageRepositoryImpl @Inject constructor(
} }
} }
override val totalUnreadCount: Flow<Int>
get() = settingsRepository.observeString(StringKey.AuthUserId)
.flatMapLatest { myId ->
if (myId.isBlank()) flowOf(0)
else dao.getTotalUnreadCount(myId)
}
override fun getUnreadCountsBySender(): Flow<Map<String, Int>> =
settingsRepository.observeString(StringKey.AuthUserId)
.flatMapLatest { myId ->
if (myId.isBlank()) flowOf(emptyMap())
else dao.getUnreadCountsBySender(myId).map { list ->
list.associate { it.senderId to it.count }
}
}
override suspend fun markConversationAsRead(senderId: String) {
val myId = settingsRepository.getStringOrNull(StringKey.AuthUserId) ?: return
dao.markConversationAsRead(myId = myId, senderId = senderId)
}
private companion object { private companion object {
const val TAG = "MessageRepository" const val TAG = "MessageRepository"
const val SYSTEM_SENDER_USERNAME = "⚙️ System" const val SYSTEM_SENDER_USERNAME = "⚙️ System"

View file

@ -10,4 +10,7 @@ internal interface MessageRepository {
suspend fun fetchUsers(): Result<List<UserListItemDto>> suspend fun fetchUsers(): Result<List<UserListItemDto>>
suspend fun getMyUserId(): String? suspend fun getMyUserId(): String?
suspend fun drainPendingMessages() suspend fun drainPendingMessages()
val totalUnreadCount: Flow<Int>
fun getUnreadCountsBySender(): Flow<Map<String, Int>>
suspend fun markConversationAsRead(senderId: String)
} }

View file

@ -191,6 +191,42 @@ internal class NotificationHelper @Inject constructor(
} }
} }
/// Aktualisiert den Launcher-Badge-Zähler für ungelesene Nachrichten.
fun updateBadgeCount(count: Int) {
val notificationManager = NotificationManagerCompat.from(context)
if (count <= 0) {
notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
return
}
// If per-sender notifications are active, the summary notification is already informative.
val hasSenderNotifications = synchronized(activeSenderNotificationIds) {
activeSenderNotificationIds.isNotEmpty()
}
if (hasSenderNotifications) return
val messagesIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_OPEN_MESSAGES, true)
}
val pendingIntent = PendingIntent.getActivity(
context, SUMMARY_NOTIFICATION_ID, messagesIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val badgeNotification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_message)
.setContentTitle("Ungelesene Nachrichten")
.setContentText("$count ungelesene Nachrichten")
.setNumber(count)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(pendingIntent)
.build()
try {
notificationManager.notify(SUMMARY_NOTIFICATION_ID, badgeNotification)
} catch (_: SecurityException) {}
}
fun cancelAllMessageNotifications() { fun cancelAllMessageNotifications() {
val notificationManager = NotificationManagerCompat.from(context) val notificationManager = NotificationManagerCompat.from(context)
val ids = synchronized(activeSenderNotificationIds) { val ids = synchronized(activeSenderNotificationIds) {

View file

@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -107,6 +109,8 @@ internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNav
val updateViewModel: UpdateViewModel = hiltViewModel() val updateViewModel: UpdateViewModel = hiltViewModel()
val updateState by updateViewModel.uiState.collectAsStateWithLifecycle() val updateState by updateViewModel.uiState.collectAsStateWithLifecycle()
val totalUnreadCount by mainViewModel.totalUnreadCount.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { topBar = {
if (showTopBar) { if (showTopBar) {
@ -152,6 +156,14 @@ internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNav
restoreState = true restoreState = true
} }
} }
) {
if (destination == TopLevelDestination.MESSAGES && totalUnreadCount > 0) {
BadgedBox(
badge = {
Badge {
Text(if (totalUnreadCount > 99) "99+" else totalUnreadCount.toString())
}
}
) { ) {
Icon( Icon(
imageVector = if (isSelected) { imageVector = if (isSelected) {
@ -167,6 +179,22 @@ internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNav
} }
) )
} }
} else {
Icon(
imageVector = if (isSelected) {
destination.selectedIcon
} else {
destination.unselectedIcon
},
contentDescription = destination.label,
tint = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
} }
} }
} }

View file

@ -9,12 +9,16 @@ import de.bollwerk.app.data.sync.WebSocketEvent
import de.bollwerk.app.domain.AuthEventBus import de.bollwerk.app.domain.AuthEventBus
import de.bollwerk.app.domain.model.SettingsKey.StringKey import de.bollwerk.app.domain.model.SettingsKey.StringKey
import de.bollwerk.app.domain.repository.ImportExportRepository import de.bollwerk.app.domain.repository.ImportExportRepository
import de.bollwerk.app.domain.repository.MessageRepository
import de.bollwerk.app.domain.repository.SettingsRepository import de.bollwerk.app.domain.repository.SettingsRepository
import de.bollwerk.app.domain.repository.SyncService import de.bollwerk.app.domain.repository.SyncService
import de.bollwerk.app.notification.NotificationHelper import de.bollwerk.app.notification.NotificationHelper
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
import javax.inject.Inject import javax.inject.Inject
@ -26,12 +30,16 @@ internal class MainViewModel @Inject constructor(
private val webSocketClient: WebSocketClient, private val webSocketClient: WebSocketClient,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository, private val importExportRepository: ImportExportRepository,
private val notificationHelper: NotificationHelper private val notificationHelper: NotificationHelper,
private val messageRepository: MessageRepository
) : ViewModel() { ) : ViewModel() {
private val _navigateToSettings = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _navigateToSettings = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val navigateToSettings: SharedFlow<Unit> = _navigateToSettings.asSharedFlow() val navigateToSettings: SharedFlow<Unit> = _navigateToSettings.asSharedFlow()
val totalUnreadCount: StateFlow<Int> = messageRepository.totalUnreadCount
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
init { init {
connectOnStartup() connectOnStartup()
observeLoginSuccess() observeLoginSuccess()

View file

@ -43,6 +43,7 @@ internal class ChatViewModel @Inject constructor(
if (myId.isNotEmpty()) { if (myId.isNotEmpty()) {
messageRepository.getConversation(myId, recipientId).collect { messages -> messageRepository.getConversation(myId, recipientId).collect { messages ->
_uiState.update { it.copy(messages = messages) } _uiState.update { it.copy(messages = messages) }
messageRepository.markConversationAsRead(recipientId)
} }
} }
} }

View file

@ -10,6 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -36,6 +38,7 @@ internal fun UserListScreen(
viewModel: UserListViewModel = hiltViewModel() viewModel: UserListViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { topBar = {
@ -66,7 +69,11 @@ internal fun UserListScreen(
) )
else -> LazyColumn { else -> LazyColumn {
items(uiState.users) { user -> items(uiState.users) { user ->
UserListItem(user = user, onClick = { onUserClick(user.id, user.username) }) UserListItem(
user = user,
unreadCount = unreadCounts[user.id] ?: 0,
onClick = { onUserClick(user.id, user.username) }
)
} }
} }
} }
@ -75,12 +82,17 @@ internal fun UserListScreen(
} }
@Composable @Composable
private fun UserListItem(user: UserListItemDto, onClick: () -> Unit) { private fun UserListItem(user: UserListItemDto, unreadCount: Int, onClick: () -> Unit) {
ListItem( ListItem(
headlineContent = { Text(user.username) }, headlineContent = { Text(user.username) },
leadingContent = { leadingContent = {
Icon(imageVector = Icons.Outlined.Person, contentDescription = null) Icon(imageVector = Icons.Outlined.Person, contentDescription = null)
}, },
trailingContent = {
if (unreadCount > 0) {
Badge { Text(if (unreadCount > 99) "99+" else unreadCount.toString()) }
}
},
modifier = Modifier.clickable(onClick = onClick) modifier = Modifier.clickable(onClick = onClick)
) )
HorizontalDivider() HorizontalDivider()

View file

@ -6,7 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.bollwerk.app.domain.repository.MessageRepository import de.bollwerk.app.domain.repository.MessageRepository
import de.bollwerk.shared.model.UserListItemDto import de.bollwerk.shared.model.UserListItemDto
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -24,6 +26,9 @@ internal class UserListViewModel @Inject constructor(
private val _uiState = MutableStateFlow(UserListUiState(isLoading = true)) private val _uiState = MutableStateFlow(UserListUiState(isLoading = true))
val uiState: StateFlow<UserListUiState> = _uiState val uiState: StateFlow<UserListUiState> = _uiState
val unreadCounts: StateFlow<Map<String, Int>> = messageRepository.getUnreadCountsBySender()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
init { init {
loadUsers() loadUsers()
} }

View file

@ -1,6 +1,7 @@
package de.bollwerk.app.data.repository package de.bollwerk.app.data.repository
import de.bollwerk.app.data.db.dao.MessageDao import de.bollwerk.app.data.db.dao.MessageDao
import de.bollwerk.app.data.db.dao.UnreadCountBySender
import de.bollwerk.app.data.db.entity.MessageEntity import de.bollwerk.app.data.db.entity.MessageEntity
import de.bollwerk.app.data.security.E2EEKeyManager import de.bollwerk.app.data.security.E2EEKeyManager
import de.bollwerk.app.data.sync.WebSocketClient import de.bollwerk.app.data.sync.WebSocketClient
@ -31,6 +32,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -45,6 +47,7 @@ import org.junit.Test
private class FakeMessageDao : MessageDao { private class FakeMessageDao : MessageDao {
val upserted = mutableListOf<MessageEntity>() val upserted = mutableListOf<MessageEntity>()
val delivered = mutableListOf<String>() val delivered = mutableListOf<String>()
val markedAsRead = mutableListOf<Pair<String, String>>() // myId to senderId
override suspend fun upsert(message: MessageEntity) { upserted.add(message) } override suspend fun upsert(message: MessageEntity) { upserted.add(message) }
override suspend fun insertIfNotExists(message: MessageEntity) { upserted.add(message) } override suspend fun insertIfNotExists(message: MessageEntity) { upserted.add(message) }
@ -60,6 +63,11 @@ private class FakeMessageDao : MessageDao {
val idx = upserted.indexOfFirst { it.id == id } val idx = upserted.indexOfFirst { it.id == id }
if (idx >= 0) upserted[idx] = upserted[idx].copy(isPending = false) if (idx >= 0) upserted[idx] = upserted[idx].copy(isPending = false)
} }
override fun getUnreadCountsBySender(myId: String): Flow<List<UnreadCountBySender>> = flowOf(emptyList())
override fun getTotalUnreadCount(myId: String): Flow<Int> = flowOf(0)
override suspend fun markConversationAsRead(myId: String, senderId: String) {
markedAsRead.add(myId to senderId)
}
} }
private class FakeMessageSettingsRepository : SettingsRepository { private class FakeMessageSettingsRepository : SettingsRepository {
@ -277,6 +285,31 @@ class MessageRepositoryImplTest {
// Then decryptMessage is mocked to return plaintext as-is // Then decryptMessage is mocked to return plaintext as-is
assertTrue(dao.upserted.any { it.id == "m1" && !it.isPending }) assertTrue(dao.upserted.any { it.id == "m1" && !it.isPending })
assertTrue("New incoming message must be stored as unread",
dao.upserted.any { it.id == "m1" && !it.isRead })
}
@Test
fun test_markConversationAsRead_delegatesToDao() = runTest {
// Given
val dao = FakeMessageDao()
val settings = FakeMessageSettingsRepository().apply {
set(SettingsKeys.AUTH_USER_ID, "user1")
}
val repo = buildRepository(
dao = dao,
httpClient = createClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }),
settings = settings
)
// When
repo.markConversationAsRead(senderId = "user2")
// Then
assertTrue(
"markConversationAsRead must forward myId and senderId to DAO",
dao.markedAsRead.contains("user1" to "user2")
)
} }
@Test @Test

View file

@ -10,6 +10,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
@ -29,7 +30,10 @@ private class FakeChatMessageRepository(
var sendMessageCalled = false var sendMessageCalled = false
var lastSentBody: String? = null var lastSentBody: String? = null
override val totalUnreadCount: Flow<Int> = flowOf(0)
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> = conversation override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> = conversation
override fun getUnreadCountsBySender(): Flow<Map<String, Int>> = flowOf(emptyMap())
override suspend fun markConversationAsRead(senderId: String) = Unit
override suspend fun sendMessage(recipientId: String, body: String) { override suspend fun sendMessage(recipientId: String, body: String) {
sendMessageCalled = true sendMessageCalled = true
lastSentBody = body lastSentBody = body

View file

@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
@ -27,8 +28,11 @@ private class FakeUserListMessageRepository(
) : MessageRepository { ) : MessageRepository {
var fetchCount = 0 var fetchCount = 0
override val totalUnreadCount: Flow<Int> = flowOf(0)
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> = override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> =
MutableStateFlow(emptyList()) MutableStateFlow(emptyList())
override fun getUnreadCountsBySender(): Flow<Map<String, Int>> = flowOf(emptyMap())
override suspend fun markConversationAsRead(senderId: String) = Unit
override suspend fun sendMessage(recipientId: String, body: String) = Unit override suspend fun sendMessage(recipientId: String, body: String) = Unit
override suspend fun fetchUsers(): Result<List<UserListItemDto>> { override suspend fun fetchUsers(): Result<List<UserListItemDto>> {
fetchCount++ fetchCount++