From 8b9c18637a6729d571e631b9c95a36872de48baa Mon Sep 17 00:00:00 2001 From: omigamedev Date: Mon, 2 Mar 2026 09:40:13 +0100 Subject: [PATCH] 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 --- .../data/repository/StreamPlanRepository.kt | 6 +- .../ui/dashboard/DashboardScreen.kt | 138 +++++++++++++----- .../ui/dashboard/DashboardViewModel.kt | 20 ++- 3 files changed, 123 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt b/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt index e9f27da..e2ca894 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/repository/StreamPlanRepository.kt @@ -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) } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt index 2e8ee13..945bc71 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardScreen.kt @@ -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, + ) + } } } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt index 7b63ed9..be08195 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/dashboard/DashboardViewModel.kt @@ -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> = streamPlanRepository.observePlans() @@ -39,6 +42,11 @@ class DashboardViewModel @Inject constructor( val streamingManagerInstance: StreamingManager = streamingManager val unreadCounts: StateFlow> = chatRepository.unreadCounts + val chatConnectionStatus: StateFlow> = chatRepository.connectionStatus + + // Linked account display names keyed by account ID (for building Twitch URLs) + private val _accountDisplayNames = MutableStateFlow>(emptyMap()) + val accountDisplayNames: StateFlow> = _accountDisplayNames.asStateFlow() private val _backendHealthy = MutableStateFlow(null) val backendHealthy: StateFlow = _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) {} } }