From 00b28d2f58787c51bdd7973d27f929f3c8200c12 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Tue, 19 May 2026 22:34:43 +0200 Subject: [PATCH] feat(contacts): show online status in user list (#131) --- .../app/ui/messaging/UserListScreen.kt | 23 +++++++++++++- .../app/ui/messaging/UserListViewModel.kt | 30 +++++++++++++++++++ .../de/bollwerk/server/routes/UserRoutes.kt | 7 ++++- .../server/websocket/WebSocketManager.kt | 7 ++++- .../bollwerk/shared/model/UserListItemDto.kt | 3 +- 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/de/bollwerk/app/ui/messaging/UserListScreen.kt b/app/src/main/java/de/bollwerk/app/ui/messaging/UserListScreen.kt index 6a6b6b1..87b9bee 100644 --- a/app/src/main/java/de/bollwerk/app/ui/messaging/UserListScreen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/messaging/UserListScreen.kt @@ -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 diff --git a/app/src/main/java/de/bollwerk/app/ui/messaging/UserListViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/messaging/UserListViewModel.kt index 164faf7..51d4819 100644 --- a/app/src/main/java/de/bollwerk/app/ui/messaging/UserListViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/messaging/UserListViewModel.kt @@ -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> = 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" + } } diff --git a/server/src/main/kotlin/de/bollwerk/server/routes/UserRoutes.kt b/server/src/main/kotlin/de/bollwerk/server/routes/UserRoutes.kt index 6c63ae1..0a41373 100644 --- a/server/src/main/kotlin/de/bollwerk/server/routes/UserRoutes.kt +++ b/server/src/main/kotlin/de/bollwerk/server/routes/UserRoutes.kt @@ -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) } diff --git a/server/src/main/kotlin/de/bollwerk/server/websocket/WebSocketManager.kt b/server/src/main/kotlin/de/bollwerk/server/websocket/WebSocketManager.kt index 13ca631..75b58bd 100644 --- a/server/src/main/kotlin/de/bollwerk/server/websocket/WebSocketManager.kt +++ b/server/src/main/kotlin/de/bollwerk/server/websocket/WebSocketManager.kt @@ -13,17 +13,22 @@ import java.util.concurrent.CopyOnWriteArraySet internal class WebSocketManager { private val sessions = ConcurrentHashMap>() + private val lastSeenMap = ConcurrentHashMap() 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") diff --git a/shared/src/main/kotlin/de/bollwerk/shared/model/UserListItemDto.kt b/shared/src/main/kotlin/de/bollwerk/shared/model/UserListItemDto.kt index 3213841..85032c2 100644 --- a/shared/src/main/kotlin/de/bollwerk/shared/model/UserListItemDto.kt +++ b/shared/src/main/kotlin/de/bollwerk/shared/model/UserListItemDto.kt @@ -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 )