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
This commit is contained in:
2026-03-02 23:07:33 +01:00
parent 8b9c18637a
commit d44fe488bd
7 changed files with 287 additions and 11 deletions

View File

@@ -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 ────────────────────────────────────────────

View File

@@ -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")

View File

@@ -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")
}
}
}
}
}

View File

@@ -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<List<LinkedAccount>> = accountRepository.observeAccounts()
@@ -53,6 +57,18 @@ class AccountsViewModel @Inject constructor(
private val _isCreatingCustomRtmp = MutableStateFlow(false)
val isCreatingCustomRtmp: StateFlow<Boolean> = _isCreatingCustomRtmp.asStateFlow()
// Pairing code state
private val _pairingCode = MutableStateFlow<String?>(null)
val pairingCode: StateFlow<String?> = _pairingCode.asStateFlow()
private val _pairingExpiresAt = MutableStateFlow<Long?>(null)
val pairingExpiresAt: StateFlow<Long?> = _pairingExpiresAt.asStateFlow()
private val _pairingLoading = MutableStateFlow(false)
val pairingLoading: StateFlow<Boolean> = _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
}
}
}
}

View File

@@ -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()

View File

@@ -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,
)
}
}
}
}

View File

@@ -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<String> = _defaultExecutionMode.asStateFlow()
private val _isPublic = MutableStateFlow(false)
val isPublic: StateFlow<Boolean> = _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" }