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 displayName: String,
val email: String?, val email: String?,
val avatarUrl: 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 ──────────────────────────────────────────── // ── Providers ────────────────────────────────────────────

View File

@@ -23,6 +23,15 @@ interface LckApiService {
@POST("auth/logout") @POST("auth/logout")
suspend fun logout(): SuccessResponse 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 ──────────────────────────────────────── // ── Providers ────────────────────────────────────────
@GET("providers/accounts") @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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.LinkOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -36,7 +40,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -52,6 +62,9 @@ fun AccountsScreen(
val customUrl by viewModel.customRtmpUrl.collectAsStateWithLifecycle() val customUrl by viewModel.customRtmpUrl.collectAsStateWithLifecycle()
val customKey by viewModel.customRtmpKey.collectAsStateWithLifecycle() val customKey by viewModel.customRtmpKey.collectAsStateWithLifecycle()
val isCreating by viewModel.isCreatingCustomRtmp.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 snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current val context = LocalContext.current
@@ -123,6 +136,18 @@ fun AccountsScreen(
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.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)) } item { Spacer(Modifier.height(8.dp)) }
items(accounts, key = { it.id }) { account -> 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.browser.customtabs.CustomTabsIntent
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.omixlab.lckcontrol.data.remote.LckApiService
import com.omixlab.lckcontrol.data.repository.AccountRepository import com.omixlab.lckcontrol.data.repository.AccountRepository
import com.omixlab.lckcontrol.shared.LinkedAccount import com.omixlab.lckcontrol.shared.LinkedAccount
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -29,6 +32,7 @@ val ALL_PROVIDERS = listOf(
@HiltViewModel @HiltViewModel
class AccountsViewModel @Inject constructor( class AccountsViewModel @Inject constructor(
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val apiService: LckApiService,
) : ViewModel() { ) : ViewModel() {
val accounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts() val accounts: StateFlow<List<LinkedAccount>> = accountRepository.observeAccounts()
@@ -53,6 +57,18 @@ class AccountsViewModel @Inject constructor(
private val _isCreatingCustomRtmp = MutableStateFlow(false) private val _isCreatingCustomRtmp = MutableStateFlow(false)
val isCreatingCustomRtmp: StateFlow<Boolean> = _isCreatingCustomRtmp.asStateFlow() 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 { init {
// Sync accounts from backend on load // Sync accounts from backend on load
viewModelScope.launch { viewModelScope.launch {
@@ -140,4 +156,40 @@ class AccountsViewModel @Inject constructor(
fun clearError() { fun clearError() {
_linkError.value = null _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 YouTubeRed = Color(0xFFFF0000)
private val TwitchPurple = Color(0xFF9146FF) private val TwitchPurple = Color(0xFF9146FF)
private val PortalTeal = Color(0xFF00BCD4)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -67,8 +68,18 @@ fun ChatScreen(
val status = connectionMap[chatKey] val status = connectionMap[chatKey]
val isConnected = status?.connected ?: false val isConnected = status?.connected ?: false
val serviceColor = if (viewModel.service == "YOUTUBE") YouTubeRed else TwitchPurple val serviceColor = when (viewModel.service) {
val serviceLabel = if (viewModel.service == "YOUTUBE") "YouTube Chat" else "Twitch Chat" "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("") } var inputText by remember { mutableStateOf("") }
val listState = rememberLazyListState() val listState = rememberLazyListState()

View File

@@ -34,6 +34,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -71,6 +72,7 @@ fun DashboardScreen(
val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle() val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle()
val chatStatus by viewModel.chatConnectionStatus.collectAsStateWithLifecycle() val chatStatus by viewModel.chatConnectionStatus.collectAsStateWithLifecycle()
val accountNames by viewModel.accountDisplayNames.collectAsStateWithLifecycle() val accountNames by viewModel.accountDisplayNames.collectAsStateWithLifecycle()
val isPublic by viewModel.isPublic.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
Scaffold( Scaffold(
@@ -151,9 +153,17 @@ fun DashboardScreen(
// Live destinations section — shows each service with chat + browser buttons // Live destinations section — shows each service with chat + browser buttons
val liveDestinations = plans.flatMap { plan -> val liveDestinations = plans.flatMap { plan ->
if (plan.status == "LIVE") { if (plan.status == "LIVE") {
plan.destinations.filter { val dests = 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) }
// 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() } else emptyList()
} }
if (liveDestinations.isNotEmpty()) { 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 { item {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium) Text("Default Streaming Mode", style = MaterialTheme.typography.titleMedium)
@@ -300,6 +332,7 @@ private fun PlanCard(
private val YouTubeRed = Color(0xFFFF0000) private val YouTubeRed = Color(0xFFFF0000)
private val TwitchPurple = Color(0xFF9146FF) private val TwitchPurple = Color(0xFF9146FF)
private val PortalTeal = Color(0xFF00BCD4)
@Composable @Composable
private fun LiveDestinationCard( private fun LiveDestinationCard(
@@ -310,8 +343,18 @@ private fun LiveDestinationCard(
onChatClick: () -> Unit, onChatClick: () -> Unit,
onBrowserClick: () -> Unit, onBrowserClick: () -> Unit,
) { ) {
val serviceColor = if (service == "YOUTUBE") YouTubeRed else TwitchPurple val serviceColor = when (service) {
val serviceLabel = if (service == "YOUTUBE") "YouTube" else "Twitch" "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 val hasFailed = connectionStatus != null && !connectionStatus.connected && connectionStatus.error != null
ElevatedCard(modifier = Modifier.fillMaxWidth()) { ElevatedCard(modifier = Modifier.fillMaxWidth()) {
@@ -366,12 +409,14 @@ private fun LiveDestinationCard(
) )
} }
} }
IconButton(onClick = onBrowserClick) { if (service != "PORTAL") {
Icon( IconButton(onClick = onBrowserClick) {
Icons.Default.OpenInBrowser, Icon(
contentDescription = "Open in browser", Icons.Default.OpenInBrowser,
tint = MaterialTheme.colorScheme.onSurfaceVariant, 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.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.data.remote.UpdateProfileRequest
import com.omixlab.lckcontrol.shared.StreamPlan import com.omixlab.lckcontrol.shared.StreamPlan
import com.omixlab.lckcontrol.streaming.StreamingManager import com.omixlab.lckcontrol.streaming.StreamingManager
import com.omixlab.lckcontrol.streaming.StreamingState import com.omixlab.lckcontrol.streaming.StreamingState
@@ -57,10 +58,20 @@ class DashboardViewModel @Inject constructor(
private val _defaultExecutionMode = MutableStateFlow(appPreferences.getDefaultExecutionMode()) private val _defaultExecutionMode = MutableStateFlow(appPreferences.getDefaultExecutionMode())
val defaultExecutionMode: StateFlow<String> = _defaultExecutionMode.asStateFlow() val defaultExecutionMode: StateFlow<String> = _defaultExecutionMode.asStateFlow()
private val _isPublic = MutableStateFlow(false)
val isPublic: StateFlow<Boolean> = _isPublic.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
try { streamPlanRepository.syncPlans() } catch (_: Exception) {} try { streamPlanRepository.syncPlans() } catch (_: Exception) {}
} }
viewModelScope.launch {
try {
val profile = apiService.getMe()
_isPublic.value = profile.isPublic
} catch (_: Exception) {}
}
viewModelScope.launch { viewModelScope.launch {
try { try {
val accounts = accountRepository.getAccounts() val accounts = accountRepository.getAccounts()
@@ -86,6 +97,17 @@ class DashboardViewModel @Inject constructor(
appPreferences.setDefaultExecutionMode(mode) 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() { fun clearStalePlans() {
viewModelScope.launch { viewModelScope.launch {
val stale = plans.value.filter { it.status == "ENDED" || it.status == "DRAFT" } val stale = plans.value.filter { it.status == "ENDED" || it.status == "DRAFT" }