From d44fe488bde40ea63a43457f55a996c352b442e3 Mon Sep 17 00:00:00 2001 From: omigamedev Date: Mon, 2 Mar 2026 23:07:33 +0100 Subject: [PATCH] Pairing code generation in Accounts tab, portal chat support - Add PairingCodeResponse, PairingStatusResponse models + API endpoints - Add pairing code card to Accounts screen with countdown + auto-dismiss on redeem - Add Portal service color/label to chat and dashboard live section - Move portal visibility toggle to dashboard, add UpdateProfileRequest --- .../lckcontrol/data/remote/ApiModels.kt | 22 ++++ .../lckcontrol/data/remote/LckApiService.kt | 9 ++ .../lckcontrol/ui/accounts/AccountsScreen.kt | 115 ++++++++++++++++++ .../ui/accounts/AccountsViewModel.kt | 52 ++++++++ .../omixlab/lckcontrol/ui/chat/ChatScreen.kt | 15 ++- .../ui/dashboard/DashboardScreen.kt | 63 ++++++++-- .../ui/dashboard/DashboardViewModel.kt | 22 ++++ 7 files changed, 287 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt index 4ef5674..1015fc9 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/ApiModels.kt @@ -38,6 +38,28 @@ data class UserProfileResponse( val displayName: String, val email: String?, val avatarUrl: String?, + val bio: String = "", + val isPublic: Boolean = false, +) + +@JsonClass(generateAdapter = true) +data class PairingCodeResponse( + val code: String, + val expiresAt: String, +) + +@JsonClass(generateAdapter = true) +data class PairingStatusResponse( + val active: Boolean, + val code: String? = null, + val expiresAt: String? = null, +) + +@JsonClass(generateAdapter = true) +data class UpdateProfileRequest( + val displayName: String? = null, + val bio: String? = null, + val isPublic: Boolean? = null, ) // ── Providers ──────────────────────────────────────────── diff --git a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt index f4af030..182caa6 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/data/remote/LckApiService.kt @@ -23,6 +23,15 @@ interface LckApiService { @POST("auth/logout") suspend fun logout(): SuccessResponse + @PATCH("auth/me") + suspend fun updateProfile(@Body body: UpdateProfileRequest): UserProfileResponse + + @POST("auth/pairing/generate") + suspend fun generatePairingCode(): PairingCodeResponse + + @GET("auth/pairing/status") + suspend fun getPairingStatus(): PairingStatusResponse + // ── Providers ──────────────────────────────────────── @GET("providers/accounts") diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt index 29e7d54..27dc108 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsScreen.kt @@ -9,12 +9,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -36,7 +40,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -52,6 +62,9 @@ fun AccountsScreen( val customUrl by viewModel.customRtmpUrl.collectAsStateWithLifecycle() val customKey by viewModel.customRtmpKey.collectAsStateWithLifecycle() val isCreating by viewModel.isCreatingCustomRtmp.collectAsStateWithLifecycle() + val pairingCode by viewModel.pairingCode.collectAsStateWithLifecycle() + val pairingExpiresAt by viewModel.pairingExpiresAt.collectAsStateWithLifecycle() + val pairingLoading by viewModel.pairingLoading.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current @@ -123,6 +136,18 @@ fun AccountsScreen( .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + item { + Spacer(Modifier.height(8.dp)) + Text("Portal Pairing", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + PairingCodeCard( + code = pairingCode, + expiresAt = pairingExpiresAt, + loading = pairingLoading, + onGenerate = viewModel::generatePairingCode, + ) + } + item { Spacer(Modifier.height(8.dp)) } items(accounts, key = { it.id }) { account -> @@ -188,3 +213,93 @@ fun AccountsScreen( } } } + +@Composable +private fun PairingCodeCard( + code: String?, + expiresAt: Long?, + loading: Boolean, + onGenerate: () -> Unit, +) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (code != null && expiresAt != null) { + val remainingSeconds = ((expiresAt - System.currentTimeMillis()) / 1000).coerceAtLeast(0) + + // Countdown ticker + val tickState = remember { MutableStateFlow(remainingSeconds) } + val currentRemaining by tickState.collectAsStateWithLifecycle() + LaunchedEffect(expiresAt) { + while (true) { + val r = ((expiresAt - System.currentTimeMillis()) / 1000).coerceAtLeast(0) + tickState.value = r + if (r <= 0) break + delay(1_000) + } + } + + if (currentRemaining > 0) { + Text( + "Enter this code on the portal:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = code.chunked(3).joinToString(" "), + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + letterSpacing = androidx.compose.ui.unit.TextUnit(4f, androidx.compose.ui.unit.TextUnitType.Sp), + ), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + val minutes = currentRemaining / 60 + val seconds = currentRemaining % 60 + Text( + text = "Expires in %d:%02d".format(minutes, seconds), + style = MaterialTheme.typography.bodySmall, + color = if (currentRemaining < 60) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton(onClick = onGenerate, enabled = !loading) { + Text("Generate New Code") + } + } else { + Text( + "Code expired", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Button(onClick = onGenerate, enabled = !loading) { + Text("Generate New Code") + } + } + } else { + Text( + "Link your portal account by generating a pairing code", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(12.dp)) + Button(onClick = onGenerate, enabled = !loading) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + } + Text("Link Portal") + } + } + } + } +} diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt index 00ec595..2903d21 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/accounts/AccountsViewModel.kt @@ -5,9 +5,12 @@ import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.omixlab.lckcontrol.data.remote.LckApiService import com.omixlab.lckcontrol.data.repository.AccountRepository import com.omixlab.lckcontrol.shared.LinkedAccount import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -29,6 +32,7 @@ val ALL_PROVIDERS = listOf( @HiltViewModel class AccountsViewModel @Inject constructor( private val accountRepository: AccountRepository, + private val apiService: LckApiService, ) : ViewModel() { val accounts: StateFlow> = accountRepository.observeAccounts() @@ -53,6 +57,18 @@ class AccountsViewModel @Inject constructor( private val _isCreatingCustomRtmp = MutableStateFlow(false) val isCreatingCustomRtmp: StateFlow = _isCreatingCustomRtmp.asStateFlow() + // Pairing code state + private val _pairingCode = MutableStateFlow(null) + val pairingCode: StateFlow = _pairingCode.asStateFlow() + + private val _pairingExpiresAt = MutableStateFlow(null) + val pairingExpiresAt: StateFlow = _pairingExpiresAt.asStateFlow() + + private val _pairingLoading = MutableStateFlow(false) + val pairingLoading: StateFlow = _pairingLoading.asStateFlow() + + private var pairingPollJob: Job? = null + init { // Sync accounts from backend on load viewModelScope.launch { @@ -140,4 +156,40 @@ class AccountsViewModel @Inject constructor( fun clearError() { _linkError.value = null } + + fun generatePairingCode() { + _pairingLoading.value = true + viewModelScope.launch { + try { + val response = apiService.generatePairingCode() + _pairingCode.value = response.code + _pairingExpiresAt.value = java.time.Instant.parse(response.expiresAt).toEpochMilli() + startPairingPoll() + } catch (_: Exception) { + _pairingCode.value = null + _pairingExpiresAt.value = null + } finally { + _pairingLoading.value = false + } + } + } + + private fun startPairingPoll() { + pairingPollJob?.cancel() + pairingPollJob = viewModelScope.launch { + while (true) { + delay(3_000) + try { + val status = apiService.getPairingStatus() + if (!status.active) { + _pairingCode.value = null + _pairingExpiresAt.value = null + break + } + } catch (_: Exception) {} + val expiresAt = _pairingExpiresAt.value ?: break + if (System.currentTimeMillis() >= expiresAt) break + } + } + } } diff --git a/app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt index 1601b46..bb70e92 100644 --- a/app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/omixlab/lckcontrol/ui/chat/ChatScreen.kt @@ -52,6 +52,7 @@ import java.util.Locale private val YouTubeRed = Color(0xFFFF0000) private val TwitchPurple = Color(0xFF9146FF) +private val PortalTeal = Color(0xFF00BCD4) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -67,8 +68,18 @@ fun ChatScreen( val status = connectionMap[chatKey] val isConnected = status?.connected ?: false - val serviceColor = if (viewModel.service == "YOUTUBE") YouTubeRed else TwitchPurple - val serviceLabel = if (viewModel.service == "YOUTUBE") "YouTube Chat" else "Twitch Chat" + val serviceColor = when (viewModel.service) { + "YOUTUBE" -> YouTubeRed + "TWITCH" -> TwitchPurple + "PORTAL" -> PortalTeal + else -> TwitchPurple + } + val serviceLabel = when (viewModel.service) { + "YOUTUBE" -> "YouTube Chat" + "TWITCH" -> "Twitch Chat" + "PORTAL" -> "Portal Comments" + else -> "${viewModel.service} Chat" + } var inputText by remember { mutableStateOf("") } val listState = rememberLazyListState() 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 945bc71..a2094ce 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 @@ -34,6 +34,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -71,6 +72,7 @@ fun DashboardScreen( val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle() val chatStatus by viewModel.chatConnectionStatus.collectAsStateWithLifecycle() val accountNames by viewModel.accountDisplayNames.collectAsStateWithLifecycle() + val isPublic by viewModel.isPublic.collectAsStateWithLifecycle() val context = LocalContext.current Scaffold( @@ -151,9 +153,17 @@ fun DashboardScreen( // Live destinations section — shows each service with chat + browser buttons val liveDestinations = plans.flatMap { plan -> if (plan.status == "LIVE") { - plan.destinations.filter { + val dests = plan.destinations.filter { it.service == "YOUTUBE" || it.service == "TWITCH" }.map { dest -> Triple(plan.planId, dest.service, dest) } + // Add synthetic PORTAL destination for live plans + val portalDest = com.omixlab.lckcontrol.shared.StreamDestination( + service = "PORTAL", + linkedAccountId = "portal", + title = "Portal Comments", + status = "LIVE", + ) + dests + Triple(plan.planId, "PORTAL", portalDest) } else emptyList() } if (liveDestinations.isNotEmpty()) { @@ -189,6 +199,28 @@ fun DashboardScreen( } } + item { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Show on Portal", style = MaterialTheme.typography.titleSmall) + Text( + "Allow others to discover your live streams", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isPublic, + onCheckedChange = viewModel::setPublicVisibility, + ) + } + } + } + item { Spacer(Modifier.height(8.dp)) Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium) @@ -300,6 +332,7 @@ private fun PlanCard( private val YouTubeRed = Color(0xFFFF0000) private val TwitchPurple = Color(0xFF9146FF) +private val PortalTeal = Color(0xFF00BCD4) @Composable private fun LiveDestinationCard( @@ -310,8 +343,18 @@ private fun LiveDestinationCard( onChatClick: () -> Unit, onBrowserClick: () -> Unit, ) { - val serviceColor = if (service == "YOUTUBE") YouTubeRed else TwitchPurple - val serviceLabel = if (service == "YOUTUBE") "YouTube" else "Twitch" + val serviceColor = when (service) { + "YOUTUBE" -> YouTubeRed + "TWITCH" -> TwitchPurple + "PORTAL" -> PortalTeal + else -> TwitchPurple + } + val serviceLabel = when (service) { + "YOUTUBE" -> "YouTube" + "TWITCH" -> "Twitch" + "PORTAL" -> "Portal" + else -> service + } val hasFailed = connectionStatus != null && !connectionStatus.connected && connectionStatus.error != null ElevatedCard(modifier = Modifier.fillMaxWidth()) { @@ -366,12 +409,14 @@ private fun LiveDestinationCard( ) } } - IconButton(onClick = onBrowserClick) { - Icon( - Icons.Default.OpenInBrowser, - contentDescription = "Open in browser", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (service != "PORTAL") { + 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 be08195..0a17b6c 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 @@ -8,6 +8,7 @@ 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.data.remote.UpdateProfileRequest import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.streaming.StreamingManager import com.omixlab.lckcontrol.streaming.StreamingState @@ -57,10 +58,20 @@ class DashboardViewModel @Inject constructor( private val _defaultExecutionMode = MutableStateFlow(appPreferences.getDefaultExecutionMode()) val defaultExecutionMode: StateFlow = _defaultExecutionMode.asStateFlow() + private val _isPublic = MutableStateFlow(false) + val isPublic: StateFlow = _isPublic.asStateFlow() + + init { viewModelScope.launch { try { streamPlanRepository.syncPlans() } catch (_: Exception) {} } + viewModelScope.launch { + try { + val profile = apiService.getMe() + _isPublic.value = profile.isPublic + } catch (_: Exception) {} + } viewModelScope.launch { try { val accounts = accountRepository.getAccounts() @@ -86,6 +97,17 @@ class DashboardViewModel @Inject constructor( appPreferences.setDefaultExecutionMode(mode) } + fun setPublicVisibility(isPublic: Boolean) { + _isPublic.value = isPublic + viewModelScope.launch { + try { + apiService.updateProfile(UpdateProfileRequest(isPublic = isPublic)) + } catch (_: Exception) { + _isPublic.value = !isPublic // revert on error + } + } + } + fun clearStalePlans() { viewModelScope.launch { val stale = plans.value.filter { it.status == "ENDED" || it.status == "DRAFT" }