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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user