feat(contacts): show online status in user list (#131)
This commit is contained in:
parent
c16c9fff97
commit
00b28d2f58
5 changed files with 66 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue