feat(#776): pairing management screen (#791)

REST + Channels-style UI for DM pairing (list pending/approved, approve, revoke, clear).

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-03 19:49:31 +03:00
1 parent b91ac044b9
commit 5870b73397
9 files changed
+785

No files matched your search

@@ -0,0 +1,337 @@
package com.m57.hermescontrol.ui.pairing
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.PairingItem
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
@Composable
fun PairingScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: PairingViewModel = viewModel { PairingViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.loadPairing()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold(
title = { Text(stringResource(R.string.screen_pairing)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadPairing() },
modifier = modifier,
) { paddingValues ->
val pairing = state.pairing
when {
state.isLoading && pairing == null -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null && pairing == null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadPairing() },
modifier = Modifier.padding(paddingValues),
)
}
pairing == null ||
(pairing.pending.isEmpty() && pairing.approved.isEmpty()) -> {
EmptyState(
title = stringResource(R.string.pairing_empty_title),
subtitle = stringResource(R.string.pairing_empty_desc),
modifier = Modifier.padding(paddingValues),
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
if (pairing.pending.isNotEmpty()) {
item(key = "pending_header") {
PendingHeader(
isClearing = state.actionKey == "clear",
onClearAll = { viewModel.clearPending() },
)
}
items(pairing.pending, key = { it.key() }) { item ->
PendingCard(
item = item,
isActing = state.actionKey == "approve:${item.requestId}",
onApprove = {
item.requestId?.let { viewModel.approvePairing(item.platform, it) }
},
)
}
}
item(key = "approved_header") {
SectionHeader(text = stringResource(R.string.pairing_sec_approved))
}
if (pairing.approved.isEmpty()) {
item(key = "approved_empty") {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
) {
Box(
modifier = Modifier.fillMaxWidth().padding(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.pairing_empty_approved),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else {
items(pairing.approved, key = { it.key() }) { item ->
ApprovedCard(
item = item,
isActing = state.actionKey == "revoke:${item.platform}:${item.userId}",
onRevoke = {
item.userId?.let { viewModel.revokePairing(item.platform, it) }
},
)
}
}
}
}
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
@Composable
private fun PendingHeader(
isClearing: Boolean,
onClearAll: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
SectionHeader(text = stringResource(R.string.pairing_sec_pending))
OutlinedButton(
onClick = onClearAll,
enabled = !isClearing,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
if (isClearing) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = stringResource(R.string.pairing_action_clear_all),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@Composable
private fun PendingCard(
item: PairingItem,
isActing: Boolean,
onApprove: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f),
),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.platform.uppercase(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
item.userName?.takeIf { it.isNotBlank() }?.let { name ->
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = item.userId ?: stringResource(R.string.pairing_label_unknown_user),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
item.ageMinutes?.let { age ->
Text(
text = formatAge(age),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Button(
onClick = onApprove,
enabled = !isActing,
) {
if (isActing) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = stringResource(R.string.pairing_action_approve),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
@Composable
private fun ApprovedCard(
item: PairingItem,
isActing: Boolean,
onRevoke: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.platform.uppercase(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
item.userName?.takeIf { it.isNotBlank() }?.let { name ->
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = item.userId ?: stringResource(R.string.pairing_label_unknown_user),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(12.dp))
OutlinedButton(
onClick = onRevoke,
enabled = !isActing,
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
if (isActing) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.width(4.dp))
}
Text(
text = stringResource(R.string.pairing_action_revoke),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
@Composable
private fun formatAge(ageMinutes: Int): String {
return if (ageMinutes < 60) {
stringResource(R.string.pairing_label_request_age_m, ageMinutes)
} else {
stringResource(R.string.pairing_label_request_age_hm, ageMinutes / 60, ageMinutes % 60)
}
}
@@ -0,0 +1,136 @@
package com.m57.hermescontrol.ui.pairing
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class PairingUiState(
val isLoading: Boolean = false,
val pairing: PairingResponse? = null,
val errorMessage: String? = null,
val toastMessage: String? = null,
/** Key of the item whose approve/revoke/clear action is in flight. */
val actionKey: String? = null,
)
class PairingViewModel :
ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(PairingUiState())
val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow()
private var launchJob: Job? = null
fun loadPairing() {
launchJob =
safeLaunchLoad(
currentJob = launchJob,
apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } },
onStart = {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
},
onSuccess = { data ->
_uiState.update { it.copy(isLoading = false, pairing = data) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load pairing: $errorMsg",
)
}
},
)
}
/** Approve a pending pairing request by its server-side `request_id`. */
fun approvePairing(
platform: String,
requestId: String,
) {
runAction(
actionKey = "approve:$requestId",
onSuccess = { it.copy(toastMessage = "Pairing request approved") },
apiCall = {
safeApiCall {
ApiClient.hermesApi.approvePairing(
PairingApproveRequest(platform = platform, requestId = requestId),
)
}
},
)
}
/** Remove a user from the approved whitelist. */
fun revokePairing(
platform: String,
userId: String,
) {
runAction(
actionKey = "revoke:$platform:$userId",
onSuccess = { it.copy(toastMessage = "Access revoked for $userId") },
apiCall = {
safeApiCall {
ApiClient.hermesApi.revokePairing(
PairingRevokeRequest(platform = platform, userId = userId),
)
}
},
)
}
/** Clear every pending pairing request. */
fun clearPending() {
runAction(
actionKey = "clear",
onSuccess = { it.copy(toastMessage = "Pending requests cleared") },
apiCall = { safeApiCall { ApiClient.hermesApi.clearPendingPairing() } },
)
}
private fun runAction(
actionKey: String,
onSuccess: (PairingUiState) -> PairingUiState,
apiCall: suspend () -> NetworkResult<Unit>,
) {
if (_uiState.value.actionKey != null) return
_uiState.update { it.copy(actionKey = actionKey) }
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() }
when (result) {
is NetworkResult.Success -> {
_uiState.update { onSuccess(it.copy(actionKey = null)) }
loadPairing()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
actionKey = null,
toastMessage = "Action failed: ${result.error.message}",
)
}
}
}
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}