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:
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user