This commit is contained in:
16 files changed
+1485
-12
No files matched your search
@@ -0,0 +1,156 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
|
||||
/**
|
||||
* Transient state for tracking live active sessions in History.
|
||||
*/
|
||||
data class SessionLiveTrackingState(
|
||||
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
|
||||
val storedIdByRuntimeId: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Pure reducer managing live active session transitions.
|
||||
*/
|
||||
object SessionLiveStatusReducer {
|
||||
fun applySnapshot(
|
||||
state: SessionLiveTrackingState,
|
||||
snapshot: LiveSessionSnapshot,
|
||||
): SessionLiveTrackingState =
|
||||
SessionLiveTrackingState(
|
||||
liveStatuses = snapshot.statusByStoredId,
|
||||
storedIdByRuntimeId = snapshot.storedIdByRuntimeId,
|
||||
)
|
||||
|
||||
fun applyWsEvent(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent,
|
||||
): SessionLiveTrackingState =
|
||||
when (event) {
|
||||
is WsEvent.SessionInfo -> reduceSessionInfo(state, event)
|
||||
is WsEvent.MessageStart -> reduceMessageStart(state, event)
|
||||
is WsEvent.MessageComplete -> reduceMessageComplete(state, event)
|
||||
is WsEvent.MessageDone -> reduceMessageDone(state, event)
|
||||
is WsEvent.ApprovalRequest -> reduceApprovalRequest(state, event)
|
||||
is WsEvent.ClarifyRequest -> reduceClarifyRequest(state, event)
|
||||
else -> state
|
||||
}
|
||||
|
||||
fun clear(): SessionLiveTrackingState = SessionLiveTrackingState()
|
||||
|
||||
private fun reduceSessionInfo(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.SessionInfo,
|
||||
): SessionLiveTrackingState {
|
||||
val data = event.data ?: return state
|
||||
val storedId = (data["stored_session_id"] as? String)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val runtimeId = (data["session_id"] as? String)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val running = data["running"] as? Boolean
|
||||
|
||||
val targetStoredId = storedId ?: runtimeId?.let { state.storedIdByRuntimeId[it] }
|
||||
if (targetStoredId == null && runtimeId == null) {
|
||||
return state
|
||||
}
|
||||
|
||||
val oldStoredId = runtimeId?.let { state.storedIdByRuntimeId[it] }
|
||||
val nextMapping =
|
||||
if (runtimeId != null && targetStoredId != null) {
|
||||
state.storedIdByRuntimeId + (runtimeId to targetStoredId)
|
||||
} else {
|
||||
state.storedIdByRuntimeId
|
||||
}
|
||||
|
||||
if (targetStoredId == null) {
|
||||
return state.copy(storedIdByRuntimeId = nextMapping)
|
||||
}
|
||||
|
||||
val nextStatuses = state.liveStatuses.toMutableMap()
|
||||
if (oldStoredId != null && oldStoredId != targetStoredId) {
|
||||
nextStatuses.remove(oldStoredId)
|
||||
}
|
||||
|
||||
when (running) {
|
||||
true -> {
|
||||
nextStatuses[targetStoredId] = SessionLiveStatus.WORKING
|
||||
}
|
||||
|
||||
false -> {
|
||||
nextStatuses.remove(targetStoredId)
|
||||
}
|
||||
|
||||
null -> {
|
||||
// If running is not specified, leave status untouched
|
||||
}
|
||||
}
|
||||
|
||||
return state.copy(
|
||||
liveStatuses = nextStatuses,
|
||||
storedIdByRuntimeId = nextMapping,
|
||||
)
|
||||
}
|
||||
|
||||
private fun reduceMessageStart(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.MessageStart,
|
||||
): SessionLiveTrackingState {
|
||||
val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state
|
||||
val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state
|
||||
if (state.liveStatuses[storedId] == SessionLiveStatus.WORKING) return state
|
||||
return state.copy(
|
||||
liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WORKING),
|
||||
)
|
||||
}
|
||||
|
||||
private fun reduceMessageComplete(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.MessageComplete,
|
||||
): SessionLiveTrackingState {
|
||||
val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val storedId =
|
||||
event.storedSessionId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: runtimeId?.let { state.storedIdByRuntimeId[it] }
|
||||
?: return state
|
||||
|
||||
if (!state.liveStatuses.containsKey(storedId)) return state
|
||||
return state.copy(
|
||||
liveStatuses = state.liveStatuses - storedId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun reduceMessageDone(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.MessageDone,
|
||||
): SessionLiveTrackingState {
|
||||
val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state
|
||||
val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state
|
||||
if (!state.liveStatuses.containsKey(storedId)) return state
|
||||
return state.copy(
|
||||
liveStatuses = state.liveStatuses - storedId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun reduceApprovalRequest(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.ApprovalRequest,
|
||||
): SessionLiveTrackingState {
|
||||
val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state
|
||||
val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state
|
||||
return state.copy(
|
||||
liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WAITING),
|
||||
)
|
||||
}
|
||||
|
||||
private fun reduceClarifyRequest(
|
||||
state: SessionLiveTrackingState,
|
||||
event: WsEvent.ClarifyRequest,
|
||||
): SessionLiveTrackingState {
|
||||
val runtimeId = event.sessionId?.trim()?.takeIf { it.isNotEmpty() } ?: return state
|
||||
val storedId = state.storedIdByRuntimeId[runtimeId] ?: return state
|
||||
return state.copy(
|
||||
liveStatuses = state.liveStatuses + (storedId to SessionLiveStatus.WAITING),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -274,6 +275,13 @@ fun SessionsScreen(
|
||||
viewModel.loadSessions()
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.startLiveStatusTracking()
|
||||
onDispose {
|
||||
viewModel.stopLiveStatusTracking()
|
||||
}
|
||||
}
|
||||
|
||||
// Toast effect
|
||||
ToastEffect(
|
||||
toastMessage = state.toastMessage,
|
||||
@@ -612,6 +620,7 @@ fun SessionsScreen(
|
||||
isSelecting = state.isSelecting,
|
||||
isSelected = session.id in state.selectedIds,
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
liveStatus = state.liveStatuses[session.id],
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
@@ -760,6 +769,7 @@ fun SessionsScreen(
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
isPinned = session.pinned == true,
|
||||
isHidden = session.hidden == true,
|
||||
liveStatus = state.liveStatuses[session.id],
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
|
||||
@@ -5,12 +5,18 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
||||
import com.m57.hermescontrol.data.model.PruneRequest
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.data.model.SessionRenameRequest
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.ChangeEventHub
|
||||
import com.m57.hermescontrol.data.ws.ChangeEvents
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesSessionLiveStatusSource
|
||||
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.refreshOnChange
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
@@ -19,6 +25,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
@@ -90,6 +97,7 @@ data class SessionsUiState(
|
||||
val searchError: String? = null,
|
||||
val showHidden: Boolean = false,
|
||||
val pinnedExpanded: Boolean = true,
|
||||
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
|
||||
) {
|
||||
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||
|
||||
@@ -103,8 +111,9 @@ data class SessionsUiState(
|
||||
get() = displaySessions.filter { it.pinned == true }
|
||||
}
|
||||
|
||||
class SessionsViewModel :
|
||||
ViewModel(),
|
||||
class SessionsViewModel(
|
||||
private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(),
|
||||
) : ViewModel(),
|
||||
ToastHost {
|
||||
private val _uiState = MutableStateFlow(SessionsUiState())
|
||||
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
||||
@@ -112,6 +121,11 @@ class SessionsViewModel :
|
||||
private var loadJob: Job? = null
|
||||
private var pageJob: Job? = null
|
||||
private var statsJob: Job? = null
|
||||
private var trackingJob: Job? = null
|
||||
private var trackingGeneration: Long = 0
|
||||
private var liveTrackingState = SessionLiveTrackingState()
|
||||
private var liveStatusRefreshInFlight = false
|
||||
private var liveStatusRefreshPending = false
|
||||
private var generation: Long = 0
|
||||
private var rawPaginationOffset: Int = 0
|
||||
|
||||
@@ -172,6 +186,7 @@ class SessionsViewModel :
|
||||
const val SEARCH_DEBOUNCE_MS = 300L
|
||||
const val MAX_STITCH_PER_PAGE = 8
|
||||
const val MAX_STITCH_ROUNDS = 3
|
||||
const val LIVE_STATUS_POLL_INTERVAL_MS = 30_000L
|
||||
}
|
||||
|
||||
private val resolvedParentCache = mutableMapOf<String, SessionInfo>()
|
||||
@@ -259,6 +274,9 @@ class SessionsViewModel :
|
||||
val section = _uiState.value.section
|
||||
pageJob?.cancel()
|
||||
loadEmptyCount()
|
||||
if (trackingJob?.isActive == true) {
|
||||
refreshLiveStatuses()
|
||||
}
|
||||
loadJob =
|
||||
safeLaunchLoad(
|
||||
currentJob = loadJob,
|
||||
@@ -863,4 +881,106 @@ class SessionsViewModel :
|
||||
override fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
// ── Live Session Status Tracking (issue #1101) ───────────────────────
|
||||
|
||||
fun startLiveStatusTracking() {
|
||||
if (trackingJob?.isActive == true) return
|
||||
trackingJob =
|
||||
viewModelScope.launch {
|
||||
// Connection status observer: non-connected clears immediately, CONNECTED rehydrates.
|
||||
launch {
|
||||
liveStatusSource.connectionStatus.collect { status ->
|
||||
if (status != ConnectionStatus.CONNECTED) {
|
||||
trackingGeneration++
|
||||
liveTrackingState = SessionLiveStatusReducer.clear()
|
||||
_uiState.update { it.copy(liveStatuses = emptyMap()) }
|
||||
} else {
|
||||
requestLiveStatusSnapshot(++trackingGeneration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Live WebSocket events observer.
|
||||
launch {
|
||||
liveStatusSource.events.collect { event ->
|
||||
liveTrackingState = SessionLiveStatusReducer.applyWsEvent(liveTrackingState, event)
|
||||
_uiState.update { it.copy(liveStatuses = liveTrackingState.liveStatuses) }
|
||||
}
|
||||
}
|
||||
|
||||
// Change events observer: sessions.changed triggers a snapshot refresh.
|
||||
launch {
|
||||
ChangeEventHub.events
|
||||
.filter { it.type == ChangeEvents.SESSIONS }
|
||||
.collect {
|
||||
requestLiveStatusSnapshot(trackingGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
// Visible-only 30s poll backstop
|
||||
launch {
|
||||
while (true) {
|
||||
delay(LIVE_STATUS_POLL_INTERVAL_MS)
|
||||
requestLiveStatusSnapshot(trackingGeneration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopLiveStatusTracking() {
|
||||
trackingGeneration++
|
||||
trackingJob?.cancel()
|
||||
trackingJob = null
|
||||
liveStatusRefreshInFlight = false
|
||||
liveStatusRefreshPending = false
|
||||
liveTrackingState = SessionLiveStatusReducer.clear()
|
||||
_uiState.update { it.copy(liveStatuses = emptyMap()) }
|
||||
}
|
||||
|
||||
fun refreshLiveStatuses() {
|
||||
requestLiveStatusSnapshot(trackingGeneration)
|
||||
}
|
||||
|
||||
private fun requestLiveStatusSnapshot(gen: Long) {
|
||||
if (trackingJob?.isActive != true ||
|
||||
gen != trackingGeneration ||
|
||||
liveStatusSource.connectionStatus.value != ConnectionStatus.CONNECTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (liveStatusRefreshInFlight) {
|
||||
liveStatusRefreshPending = true
|
||||
return
|
||||
}
|
||||
liveStatusRefreshInFlight = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val snapshot = liveStatusSource.fetchActiveSessionsSnapshot()
|
||||
if (trackingJob?.isActive == true &&
|
||||
gen == trackingGeneration &&
|
||||
liveStatusSource.connectionStatus.value == ConnectionStatus.CONNECTED &&
|
||||
snapshot != null
|
||||
) {
|
||||
liveTrackingState = SessionLiveStatusReducer.applySnapshot(liveTrackingState, snapshot)
|
||||
_uiState.update { it.copy(liveStatuses = liveTrackingState.liveStatuses) }
|
||||
}
|
||||
} finally {
|
||||
liveStatusRefreshInFlight = false
|
||||
if (liveStatusRefreshPending &&
|
||||
trackingJob?.isActive == true &&
|
||||
gen == trackingGeneration &&
|
||||
liveStatusSource.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||
) {
|
||||
liveStatusRefreshPending = false
|
||||
requestLiveStatusSnapshot(gen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopLiveStatusTracking()
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
@@ -56,6 +58,7 @@ fun SearchResultCard(
|
||||
isSelecting: Boolean,
|
||||
isSelected: Boolean,
|
||||
isDeleting: Boolean,
|
||||
liveStatus: SessionLiveStatus? = null,
|
||||
highlightBackground: Color,
|
||||
highlightForeground: Color,
|
||||
onCardClick: () -> Unit,
|
||||
@@ -65,6 +68,7 @@ fun SearchResultCard(
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val snippet = session.preview?.takeIf { it.isNotBlank() } ?: stringResource(R.string.history_untitled)
|
||||
val cleanSnippet = cleanSearchSnippet(snippet)
|
||||
val playedAt = formatPlayedAt(session.started_at)
|
||||
@@ -86,6 +90,10 @@ fun SearchResultCard(
|
||||
border =
|
||||
if (isSelected) {
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
} else if (!isSelecting && liveStatus == SessionLiveStatus.WORKING) {
|
||||
BorderStroke(2.dp, statusColors.success)
|
||||
} else if (!isSelecting && liveStatus == SessionLiveStatus.WAITING) {
|
||||
BorderStroke(2.dp, statusColors.warning)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -153,6 +161,12 @@ fun SearchResultCard(
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.xs),
|
||||
) {
|
||||
if (liveStatus != null) {
|
||||
SessionLiveStatusIndicator(
|
||||
liveStatus = liveStatus,
|
||||
sessionId = session.id,
|
||||
)
|
||||
}
|
||||
session.source?.let { src ->
|
||||
StatusBadge(
|
||||
text = sourceLabel(src),
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.data.model.SessionTreeItem
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
@@ -146,6 +147,7 @@ fun SessionCard(
|
||||
isDeleting: Boolean,
|
||||
isPinned: Boolean,
|
||||
isHidden: Boolean = false,
|
||||
liveStatus: SessionLiveStatus? = null,
|
||||
highlightBackground: Color,
|
||||
highlightForeground: Color,
|
||||
onCardClick: () -> Unit,
|
||||
@@ -158,7 +160,6 @@ fun SessionCard(
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val isActive = session.status?.lowercase() == "active" || session.status?.lowercase() == "streaming"
|
||||
val srcIcon = sourceIcon(session.source)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -176,10 +177,12 @@ fun SessionCard(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
border =
|
||||
if (isActive && !isSelecting) {
|
||||
BorderStroke(2.dp, statusColors.success)
|
||||
} else if (isSelected) {
|
||||
if (isSelected) {
|
||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||
} else if (!isSelecting && liveStatus == SessionLiveStatus.WORKING) {
|
||||
BorderStroke(2.dp, statusColors.success)
|
||||
} else if (!isSelecting && liveStatus == SessionLiveStatus.WAITING) {
|
||||
BorderStroke(2.dp, statusColors.warning)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -270,11 +273,11 @@ fun SessionCard(
|
||||
status = StatusBadgeType.NEUTRAL,
|
||||
)
|
||||
}
|
||||
if (!session.status.isNullOrBlank()) {
|
||||
if (liveStatus != null) {
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
StatusBadge(
|
||||
text = session.status,
|
||||
status = if (isActive) StatusBadgeType.SUCCESS else StatusBadgeType.NEUTRAL,
|
||||
SessionLiveStatusIndicator(
|
||||
liveStatus = liveStatus,
|
||||
sessionId = session.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.m57.hermescontrol.ui.sessions.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
|
||||
@Composable
|
||||
fun SessionLiveStatusIndicator(
|
||||
liveStatus: SessionLiveStatus,
|
||||
sessionId: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
val (bgColor, fgColor, textRes, descRes) =
|
||||
when (liveStatus) {
|
||||
SessionLiveStatus.WORKING -> {
|
||||
Tuple4(
|
||||
statusColors.successContainer,
|
||||
statusColors.success,
|
||||
R.string.sessions_live_status_running,
|
||||
R.string.sessions_live_status_running_desc,
|
||||
)
|
||||
}
|
||||
|
||||
SessionLiveStatus.WAITING -> {
|
||||
Tuple4(
|
||||
statusColors.warningContainer,
|
||||
statusColors.warning,
|
||||
R.string.sessions_live_status_waiting,
|
||||
R.string.sessions_live_status_waiting_desc,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val text = stringResource(textRes)
|
||||
val description = stringResource(descRes)
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.testTag("session_live_status_$sessionId")
|
||||
.semantics { contentDescription = description },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = bgColor,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
horizontal = spacing.sm,
|
||||
vertical = 2.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
when (liveStatus) {
|
||||
SessionLiveStatus.WORKING -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(10.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = fgColor,
|
||||
)
|
||||
}
|
||||
|
||||
SessionLiveStatus.WAITING -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = fgColor,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = fgColor,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Tuple4<A, B, C, D>(
|
||||
val first: A,
|
||||
val second: B,
|
||||
val third: C,
|
||||
val fourth: D,
|
||||
)
|
||||
Reference in new issue
Block a user