feat(contacts): show online status in user list (#131)

This commit is contained in:
Jens Reinemann 2026-05-19 22:34:43 +02:00
parent c16c9fff97
commit 00b28d2f58
5 changed files with 66 additions and 4 deletions

View file

@ -11,7 +11,6 @@ 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.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
@ -83,8 +82,10 @@ internal fun UserListScreen(
@Composable @Composable
private fun UserListItem(user: UserListItemDto, unreadCount: Int, onClick: () -> Unit) { private fun UserListItem(user: UserListItemDto, unreadCount: Int, onClick: () -> Unit) {
val statusText = formatOnlineStatus(user.lastSeen)
ListItem( ListItem(
headlineContent = { Text(user.username) }, headlineContent = { Text(user.username) },
supportingContent = statusText?.let { { Text(it, style = MaterialTheme.typography.bodySmall) } },
leadingContent = { leadingContent = {
Icon(imageVector = Icons.Outlined.Person, contentDescription = null) Icon(imageVector = Icons.Outlined.Person, contentDescription = null)
}, },
@ -97,3 +98,23 @@ private fun UserListItem(user: UserListItemDto, unreadCount: Int, onClick: () ->
) )
HorizontalDivider() HorizontalDivider()
} }
private fun formatOnlineStatus(lastSeen: Long?): String? {
if (lastSeen == null) return null
val now = System.currentTimeMillis()
if (now - lastSeen < ONLINE_THRESHOLD_MS) return "Verbunden"
val dt = java.util.Date(lastSeen)
val cal = java.util.Calendar.getInstance().apply { time = dt }
val todayCal = java.util.Calendar.getInstance()
val isToday = cal.get(java.util.Calendar.YEAR) == todayCal.get(java.util.Calendar.YEAR) &&
cal.get(java.util.Calendar.DAY_OF_YEAR) == todayCal.get(java.util.Calendar.DAY_OF_YEAR)
val timeFmt = java.text.SimpleDateFormat("HH:mm", java.util.Locale.GERMAN)
return if (isToday) {
"zuletzt online um ${timeFmt.format(dt)}"
} else {
val dateFmt = java.text.SimpleDateFormat("dd.MM.", java.util.Locale.GERMAN)
"zuletzt online am ${dateFmt.format(dt)} um ${timeFmt.format(dt)}"
}
}
private const val ONLINE_THRESHOLD_MS = 60_000L

View file

@ -5,10 +5,13 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -29,8 +32,30 @@ internal class UserListViewModel @Inject constructor(
val unreadCounts: StateFlow<Map<String, Int>> = messageRepository.getUnreadCountsBySender() val unreadCounts: StateFlow<Map<String, Int>> = messageRepository.getUnreadCountsBySender()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
private var pollingJob: Job? = null
init { init {
loadUsers() loadUsers()
startPolling()
}
private fun startPolling() {
pollingJob = viewModelScope.launch {
while (isActive) {
delay(POLL_INTERVAL_MS)
refreshStatuses()
}
}
}
private suspend fun refreshStatuses() {
val result = messageRepository.fetchUsers()
if (result.isSuccess) {
val fresh = result.getOrDefault(emptyList())
_uiState.value = _uiState.value.copy(users = fresh)
} else {
android.util.Log.w(TAG, "Online-Status-Polling fehlgeschlagen: ${result.exceptionOrNull()?.message}")
}
} }
private fun loadUsers() { private fun loadUsers() {
@ -49,4 +74,9 @@ internal class UserListViewModel @Inject constructor(
} }
fun retry() = loadUsers() fun retry() = loadUsers()
private companion object {
const val POLL_INTERVAL_MS = 30_000L
const val TAG = "UserListViewModel"
}
} }

View file

@ -25,9 +25,14 @@ internal fun Route.userRoutes(userRepository: UserRepository, webSocketManager:
HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized,
ErrorResponse(status = 401, message = "Unauthorized") ErrorResponse(status = 401, message = "Unauthorized")
) )
val now = System.currentTimeMillis()
val users = userRepository.listAll() val users = userRepository.listAll()
.filter { it.id != principal.userId } .filter { it.id != principal.userId }
.map { UserListItemDto(id = it.id, username = it.username) } .map { user ->
val online = webSocketManager.isOnline(user.id)
val lastSeen = if (online) now else webSocketManager.getLastSeen(user.id)
UserListItemDto(id = user.id, username = user.username, lastSeen = lastSeen)
}
call.respond(HttpStatusCode.OK, users) call.respond(HttpStatusCode.OK, users)
} }

View file

@ -13,17 +13,22 @@ import java.util.concurrent.CopyOnWriteArraySet
internal class WebSocketManager { internal class WebSocketManager {
private val sessions = ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>>() private val sessions = ConcurrentHashMap<String, CopyOnWriteArraySet<WebSocketSession>>()
private val lastSeenMap = ConcurrentHashMap<String, Long>()
fun addSession(userId: String, session: WebSocketSession) { fun addSession(userId: String, session: WebSocketSession) {
sessions.getOrPut(userId) { CopyOnWriteArraySet() }.add(session) sessions.computeIfAbsent(userId) { CopyOnWriteArraySet() }.add(session)
lastSeenMap[userId] = System.currentTimeMillis()
} }
fun removeSession(userId: String, session: WebSocketSession) { fun removeSession(userId: String, session: WebSocketSession) {
sessions[userId]?.remove(session) sessions[userId]?.remove(session)
lastSeenMap[userId] = System.currentTimeMillis()
} }
fun isOnline(userId: String): Boolean = sessions[userId]?.isNotEmpty() == true fun isOnline(userId: String): Boolean = sessions[userId]?.isNotEmpty() == true
fun getLastSeen(userId: String): Long? = lastSeenMap[userId]
suspend fun notifyInventoryUpdated(userId: String, itemId: String) { suspend fun notifyInventoryUpdated(userId: String, itemId: String) {
val payload = buildJsonObject { val payload = buildJsonObject {
put("type", "inventoryUpdated") put("type", "inventoryUpdated")

View file

@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class UserListItemDto( data class UserListItemDto(
val id: String, val id: String,
val username: String val username: String,
val lastSeen: Long? = null
) )