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:
parent
06fa017c04
commit
512829dd49
14 changed files with 208 additions and 22 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -153,19 +157,43 @@ internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNav
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (destination == TopLevelDestination.MESSAGES && totalUnreadCount > 0) {
|
||||||
imageVector = if (isSelected) {
|
BadgedBox(
|
||||||
destination.selectedIcon
|
badge = {
|
||||||
} else {
|
Badge {
|
||||||
destination.unselectedIcon
|
Text(if (totalUnreadCount > 99) "99+" else totalUnreadCount.toString())
|
||||||
},
|
}
|
||||||
contentDescription = destination.label,
|
}
|
||||||
tint = if (isSelected) {
|
) {
|
||||||
MaterialTheme.colorScheme.primary
|
Icon(
|
||||||
} else {
|
imageVector = if (isSelected) {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
destination.selectedIcon
|
||||||
|
} else {
|
||||||
|
destination.unselectedIcon
|
||||||
|
},
|
||||||
|
contentDescription = destination.label,
|
||||||
|
tint = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isSelected) {
|
||||||
|
destination.selectedIcon
|
||||||
|
} else {
|
||||||
|
destination.unselectedIcon
|
||||||
|
},
|
||||||
|
contentDescription = destination.label,
|
||||||
|
tint = if (isSelected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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++
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue