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.outlined.Person
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@ -83,8 +82,10 @@ internal fun UserListScreen(
@Composable
private fun UserListItem(user: UserListItemDto, unreadCount: Int, onClick: () -> Unit) {
val statusText = formatOnlineStatus(user.lastSeen)
ListItem(
headlineContent = { Text(user.username) },
supportingContent = statusText?.let { { Text(it, style = MaterialTheme.typography.bodySmall) } },
leadingContent = {
Icon(imageVector = Icons.Outlined.Person, contentDescription = null)
},
@ -97,3 +98,23 @@ private fun UserListItem(user: UserListItemDto, unreadCount: Int, onClick: () ->
)
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 de.bollwerk.app.domain.repository.MessageRepository
import de.bollwerk.shared.model.UserListItemDto
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -29,8 +32,30 @@ internal class UserListViewModel @Inject constructor(
val unreadCounts: StateFlow<Map<String, Int>> = messageRepository.getUnreadCountsBySender()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
private var pollingJob: Job? = null
init {
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() {
@ -49,4 +74,9 @@ internal class UserListViewModel @Inject constructor(
}
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,
ErrorResponse(status = 401, message = "Unauthorized")
)
val now = System.currentTimeMillis()
val users = userRepository.listAll()
.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)
}

View file

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

View file

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