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