Dashboard live section with chat + browser buttons, cleanup drafts, resilient delete

- Replace Live Chat section with unified Live section showing per-destination
  cards with chat button (unread badge) and open-in-browser button
- Show error placeholder when a service fails to connect
- Cleanup button now deletes both ENDED and DRAFT plans
- deletePlan handles 404 gracefully (still removes from Room DB)
- Expose chat connection status and account display names in ViewModel
This commit is contained in:
2026-03-02 09:40:13 +01:00
parent 870139b054
commit 8b9c18637a
3 changed files with 123 additions and 41 deletions

View File

@@ -131,7 +131,11 @@ class StreamPlanRepository @Inject constructor(
}
suspend fun deletePlan(planId: String) {
apiService.deleteStreamPlan(planId)
try {
apiService.deleteStreamPlan(planId)
} catch (_: Exception) {
// Backend may have already deleted this plan (404) — still remove locally
}
planDao.deletePlan(planId)
}

View File

@@ -1,5 +1,7 @@
package com.omixlab.lckcontrol.ui.dashboard
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -18,6 +20,8 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.ClearAll
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
@@ -37,9 +41,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.omixlab.lckcontrol.data.remote.ChatConnectionStatus
import com.omixlab.lckcontrol.shared.StreamDestination
import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.streaming.StreamingState
import com.omixlab.lckcontrol.ui.components.GameInfoRow
@@ -62,6 +69,9 @@ fun DashboardScreen(
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle()
val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle()
val chatStatus by viewModel.chatConnectionStatus.collectAsStateWithLifecycle()
val accountNames by viewModel.accountDisplayNames.collectAsStateWithLifecycle()
val context = LocalContext.current
Scaffold(
topBar = {
@@ -138,27 +148,43 @@ fun DashboardScreen(
}
}
// Live Chat section — only when a LIVE plan has YouTube/Twitch destinations
val liveChatDestinations = plans.flatMap { plan ->
// Live destinations section — shows each service with chat + browser buttons
val liveDestinations = plans.flatMap { plan ->
if (plan.status == "LIVE") {
plan.destinations.filter {
it.service == "YOUTUBE" || it.service == "TWITCH"
}.map { dest -> Triple(plan.planId, dest.service, dest) }
} else emptyList()
}
if (liveChatDestinations.isNotEmpty()) {
if (liveDestinations.isNotEmpty()) {
item {
Spacer(Modifier.height(8.dp))
Text("Live Chat", style = MaterialTheme.typography.titleMedium)
Text("Live", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
}
items(liveChatDestinations, key = { "${it.first}:${it.second}:${it.third.linkedAccountId}" }) { (planId, service, dest) ->
items(liveDestinations, key = { "${it.first}:${it.second}:${it.third.linkedAccountId}" }) { (planId, service, dest) ->
val chatKey = "$planId:$service:${dest.linkedAccountId}"
val status = chatStatus[chatKey]
val unread = unreadCounts[chatKey] ?: 0
ChatServiceCard(
LiveDestinationCard(
service = service,
destination = dest,
connectionStatus = status,
unreadCount = unread,
onClick = { onNavigateToChat(planId, service, dest.linkedAccountId) },
onChatClick = { onNavigateToChat(planId, service, dest.linkedAccountId) },
onBrowserClick = {
val url = when (service) {
"YOUTUBE" -> "https://youtube.com/watch?v=${dest.broadcastId}"
"TWITCH" -> {
val name = accountNames[dest.linkedAccountId] ?: ""
"https://twitch.tv/$name"
}
else -> null
}
if (url != null) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
},
)
}
}
@@ -192,11 +218,11 @@ fun DashboardScreen(
verticalAlignment = Alignment.CenterVertically,
) {
Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
if (plans.any { it.status == "ENDED" }) {
IconButton(onClick = viewModel::clearEndedPlans) {
if (plans.any { it.status == "ENDED" || it.status == "DRAFT" }) {
IconButton(onClick = viewModel::clearStalePlans) {
Icon(
Icons.Default.ClearAll,
contentDescription = "Clear ended plans",
contentDescription = "Clear ended and draft plans",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -276,39 +302,77 @@ private val YouTubeRed = Color(0xFFFF0000)
private val TwitchPurple = Color(0xFF9146FF)
@Composable
private fun ChatServiceCard(
private fun LiveDestinationCard(
service: String,
destination: StreamDestination,
connectionStatus: ChatConnectionStatus?,
unreadCount: Int,
onClick: () -> Unit,
onChatClick: () -> Unit,
onBrowserClick: () -> Unit,
) {
val serviceColor = if (service == "YOUTUBE") YouTubeRed else TwitchPurple
val serviceLabel = if (service == "YOUTUBE") "YouTube Chat" else "Twitch Chat"
val serviceLabel = if (service == "YOUTUBE") "YouTube" else "Twitch"
val hasFailed = connectionStatus != null && !connectionStatus.connected && connectionStatus.error != null
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.AutoMirrored.Filled.Chat,
contentDescription = serviceLabel,
tint = serviceColor,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
Text(
serviceLabel,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
if (unreadCount > 0) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
if (hasFailed) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.ErrorOutline,
contentDescription = "Error",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(serviceLabel, style = MaterialTheme.typography.titleSmall)
Text(
connectionStatus?.error ?: "Failed to start",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
} else {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = serviceColor,
modifier = Modifier.size(12.dp),
)
Spacer(Modifier.width(8.dp))
Text(
serviceLabel,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
BadgedBox(badge = {
Badge { Text(if (unreadCount > 99) "99+" else unreadCount.toString()) }
}) {}
if (unreadCount > 0) {
Badge { Text(if (unreadCount > 99) "99+" else unreadCount.toString()) }
}
}) {
IconButton(onClick = onChatClick) {
Icon(
Icons.AutoMirrored.Filled.Chat,
contentDescription = "$serviceLabel Chat",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = onBrowserClick) {
Icon(
Icons.Default.OpenInBrowser,
contentDescription = "Open in browser",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View File

@@ -3,7 +3,9 @@ package com.omixlab.lckcontrol.ui.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.local.AppPreferences
import com.omixlab.lckcontrol.data.remote.ChatConnectionStatus
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.data.repository.ChatRepository
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.StreamPlan
@@ -29,6 +31,7 @@ class DashboardViewModel @Inject constructor(
val gameInfoProvider: GameInfoProvider,
private val streamingManager: StreamingManager,
private val chatRepository: ChatRepository,
private val accountRepository: AccountRepository,
) : ViewModel() {
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
@@ -39,6 +42,11 @@ class DashboardViewModel @Inject constructor(
val streamingManagerInstance: StreamingManager = streamingManager
val unreadCounts: StateFlow<Map<String, Int>> = chatRepository.unreadCounts
val chatConnectionStatus: StateFlow<Map<String, ChatConnectionStatus>> = chatRepository.connectionStatus
// Linked account display names keyed by account ID (for building Twitch URLs)
private val _accountDisplayNames = MutableStateFlow<Map<String, String>>(emptyMap())
val accountDisplayNames: StateFlow<Map<String, String>> = _accountDisplayNames.asStateFlow()
private val _backendHealthy = MutableStateFlow<Boolean?>(null)
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
@@ -53,6 +61,12 @@ class DashboardViewModel @Inject constructor(
viewModelScope.launch {
try { streamPlanRepository.syncPlans() } catch (_: Exception) {}
}
viewModelScope.launch {
try {
val accounts = accountRepository.getAccounts()
_accountDisplayNames.value = accounts.associate { it.id to it.displayName }
} catch (_: Exception) {}
}
viewModelScope.launch {
while (true) {
try {
@@ -72,10 +86,10 @@ class DashboardViewModel @Inject constructor(
appPreferences.setDefaultExecutionMode(mode)
}
fun clearEndedPlans() {
fun clearStalePlans() {
viewModelScope.launch {
val ended = plans.value.filter { it.status == "ENDED" }
for (plan in ended) {
val stale = plans.value.filter { it.status == "ENDED" || it.status == "DRAFT" }
for (plan in stale) {
try { streamPlanRepository.deletePlan(plan.planId) } catch (_: Exception) {}
}
}