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) { 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) planDao.deletePlan(planId)
} }

View File

@@ -1,5 +1,7 @@
package com.omixlab.lckcontrol.ui.dashboard package com.omixlab.lckcontrol.ui.dashboard
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.ClearAll 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
@@ -37,9 +41,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.shared.StreamPlan
import com.omixlab.lckcontrol.streaming.StreamingState import com.omixlab.lckcontrol.streaming.StreamingState
import com.omixlab.lckcontrol.ui.components.GameInfoRow import com.omixlab.lckcontrol.ui.components.GameInfoRow
@@ -62,6 +69,9 @@ fun DashboardScreen(
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle() val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle() val streamingStats by viewModel.streamingStats.collectAsStateWithLifecycle()
val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle() val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle()
val chatStatus by viewModel.chatConnectionStatus.collectAsStateWithLifecycle()
val accountNames by viewModel.accountDisplayNames.collectAsStateWithLifecycle()
val context = LocalContext.current
Scaffold( Scaffold(
topBar = { topBar = {
@@ -138,27 +148,43 @@ fun DashboardScreen(
} }
} }
// Live Chat section — only when a LIVE plan has YouTube/Twitch destinations // Live destinations section — shows each service with chat + browser buttons
val liveChatDestinations = plans.flatMap { plan -> val liveDestinations = plans.flatMap { plan ->
if (plan.status == "LIVE") { if (plan.status == "LIVE") {
plan.destinations.filter { plan.destinations.filter {
it.service == "YOUTUBE" || it.service == "TWITCH" it.service == "YOUTUBE" || it.service == "TWITCH"
}.map { dest -> Triple(plan.planId, dest.service, dest) } }.map { dest -> Triple(plan.planId, dest.service, dest) }
} else emptyList() } else emptyList()
} }
if (liveChatDestinations.isNotEmpty()) { if (liveDestinations.isNotEmpty()) {
item { item {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Live Chat", style = MaterialTheme.typography.titleMedium) Text("Live", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp)) 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 chatKey = "$planId:$service:${dest.linkedAccountId}"
val status = chatStatus[chatKey]
val unread = unreadCounts[chatKey] ?: 0 val unread = unreadCounts[chatKey] ?: 0
ChatServiceCard( LiveDestinationCard(
service = service, service = service,
destination = dest,
connectionStatus = status,
unreadCount = unread, 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, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("Stream Plans", style = MaterialTheme.typography.titleMedium) Text("Stream Plans", style = MaterialTheme.typography.titleMedium)
if (plans.any { it.status == "ENDED" }) { if (plans.any { it.status == "ENDED" || it.status == "DRAFT" }) {
IconButton(onClick = viewModel::clearEndedPlans) { IconButton(onClick = viewModel::clearStalePlans) {
Icon( Icon(
Icons.Default.ClearAll, Icons.Default.ClearAll,
contentDescription = "Clear ended plans", contentDescription = "Clear ended and draft plans",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
@@ -276,39 +302,77 @@ private val YouTubeRed = Color(0xFFFF0000)
private val TwitchPurple = Color(0xFF9146FF) private val TwitchPurple = Color(0xFF9146FF)
@Composable @Composable
private fun ChatServiceCard( private fun LiveDestinationCard(
service: String, service: String,
destination: StreamDestination,
connectionStatus: ChatConnectionStatus?,
unreadCount: Int, unreadCount: Int,
onClick: () -> Unit, onChatClick: () -> Unit,
onBrowserClick: () -> Unit,
) { ) {
val serviceColor = if (service == "YOUTUBE") YouTubeRed else TwitchPurple 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( ElevatedCard(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier if (hasFailed) {
.fillMaxWidth() Row(
.clickable(onClick = onClick), modifier = Modifier.padding(16.dp),
) { verticalAlignment = Alignment.CenterVertically,
Row( ) {
modifier = Modifier.padding(16.dp), Icon(
verticalAlignment = Alignment.CenterVertically, Icons.Default.ErrorOutline,
) { contentDescription = "Error",
Icon( tint = MaterialTheme.colorScheme.error,
Icons.AutoMirrored.Filled.Chat, modifier = Modifier.size(24.dp),
contentDescription = serviceLabel, )
tint = serviceColor, Spacer(Modifier.width(12.dp))
modifier = Modifier.size(24.dp), Column(modifier = Modifier.weight(1f)) {
) Text(serviceLabel, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.width(12.dp)) Text(
Text( connectionStatus?.error ?: "Failed to start",
serviceLabel, style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f), )
) }
if (unreadCount > 0) { }
} 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 = { 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.local.AppPreferences 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.remote.LckApiService
import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.data.repository.ChatRepository import com.omixlab.lckcontrol.data.repository.ChatRepository
import com.omixlab.lckcontrol.data.repository.StreamPlanRepository import com.omixlab.lckcontrol.data.repository.StreamPlanRepository
import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.shared.StreamPlan
@@ -29,6 +31,7 @@ class DashboardViewModel @Inject constructor(
val gameInfoProvider: GameInfoProvider, val gameInfoProvider: GameInfoProvider,
private val streamingManager: StreamingManager, private val streamingManager: StreamingManager,
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val accountRepository: AccountRepository,
) : ViewModel() { ) : ViewModel() {
val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans() val plans: StateFlow<List<StreamPlan>> = streamPlanRepository.observePlans()
@@ -39,6 +42,11 @@ class DashboardViewModel @Inject constructor(
val streamingManagerInstance: StreamingManager = streamingManager val streamingManagerInstance: StreamingManager = streamingManager
val unreadCounts: StateFlow<Map<String, Int>> = chatRepository.unreadCounts 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) private val _backendHealthy = MutableStateFlow<Boolean?>(null)
val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow() val backendHealthy: StateFlow<Boolean?> = _backendHealthy.asStateFlow()
@@ -53,6 +61,12 @@ class DashboardViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
try { streamPlanRepository.syncPlans() } catch (_: Exception) {} 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 { viewModelScope.launch {
while (true) { while (true) {
try { try {
@@ -72,10 +86,10 @@ class DashboardViewModel @Inject constructor(
appPreferences.setDefaultExecutionMode(mode) appPreferences.setDefaultExecutionMode(mode)
} }
fun clearEndedPlans() { fun clearStalePlans() {
viewModelScope.launch { viewModelScope.launch {
val ended = plans.value.filter { it.status == "ENDED" } val stale = plans.value.filter { it.status == "ENDED" || it.status == "DRAFT" }
for (plan in ended) { for (plan in stale) {
try { streamPlanRepository.deletePlan(plan.planId) } catch (_: Exception) {} try { streamPlanRepository.deletePlan(plan.planId) } catch (_: Exception) {}
} }
} }