From a8cf79d91c8b34e019a52ce0065a1ac497068672 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Tue, 16 Jun 2026 00:02:05 +0300 Subject: [PATCH] Implement Device Pairing and Config screens --- PROGRESS_STATUS.md | 37 +-- .../java/com/m57/hermescontrol/Navigation.kt | 35 ++- .../com/m57/hermescontrol/NavigationKeys.kt | 4 + .../m57/hermescontrol/data/model/Config.kt | 11 + .../m57/hermescontrol/data/model/Pairing.kt | 25 ++ .../data/remote/HermesApiService.kt | 29 ++ .../hermescontrol/ui/config/ConfigScreen.kt | 164 +++++++++++ .../ui/config/ConfigViewModel.kt | 101 +++++++ .../hermescontrol/ui/pairing/PairingScreen.kt | 271 ++++++++++++++++++ .../ui/pairing/PairingViewModel.kt | 120 ++++++++ 10 files changed, 773 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/Config.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/Pairing.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt diff --git a/PROGRESS_STATUS.md b/PROGRESS_STATUS.md index 95cb8766..0faba267 100644 --- a/PROGRESS_STATUS.md +++ b/PROGRESS_STATUS.md @@ -10,6 +10,7 @@ This document tracks the completed features and the pending backlog for the Herm * **Code Formatting**: Code formatting checks integrated using `ktlint` (`./gradlew ktlintCheck`). * **Unit Testing Suite**: Verified with `./gradlew test` (AuthManager, EventParser, ConnectViewModel, ChatViewModel). * **Network Capabilities**: Cleartext traffic enabled in `AndroidManifest.xml` to allow connection to local networks (e.g., `192.168.1.18` to `192.168.1.48`). +* **Git Repository**: Initialized and linked with GitHub (`https://github.com/Hy4ri/hermes-mobile.git`). ### 2. Implemented Features (~45 of 213 endpoints, ~21%) * **Connection Screen (`ConnectScreen`)**: Saves Host, Port, and Auth Token to `AuthManager` (EncryptedSharedPreferences), with live verification via `GET /api/status`. @@ -30,28 +31,18 @@ This document tracks the completed features and the pending backlog for the Herm ## ⏳ Left (To Be Implemented) -All remaining **168 endpoints** across the Hermes Dashboard API need to be implemented. The categories and their priority order are: +All remaining **168 endpoints** across the Hermes Dashboard API need to be implemented. The specific requested views/features: -### Priority 1: Model & Provider Management (3 endpoints) -* List model providers (`GET /api/model/providers`) -* Get and set recommended default model (`GET/POST /api/model/recommended-default`) -* **UI**: Select model directly from a dropdown in Settings or Profiles. +### 1. Model View (AI model selection & recommended configurations) +### 2. Logs View (live scrolling platform logs / agent stdout logs) +### 3. Plugins View (install, update, list agent plugin extensions) +### 4. MCP (Model Context Protocol server configurations & management) +### 5. Channels / Messaging (platform config, e.g., Telegram connection settings) +### 6. Webhooks (create and dispatch live webhooks subscriptions) +### 7. Pairing (device pairing approvals & security management) +### 8. Live Config (edit live config parameters) +### 9. Keys (live environment variables and credentials manager) +### 10. System View (detailed stats, system health, and backups) +### 11. Kanban View (boards, cards, comments, task management plugin) -### Priority 2: Skills Hub & Editing (8 endpoints) -* Browse hub, preview, install, uninstall, update skills. -* Create/edit custom skills by editing their raw YAML/markdown files (`GET/PUT /api/skills/content`). - -### Priority 3: Advanced Sessions & Search (8 endpoints) -* Search through session transcripts. -* Delete sessions in bulk, prune empty sessions, export session transcripts. -* Get session statistics. - -### Priority 4: Cron Creation & Blueprint Instantiation (3 endpoints) -* Form/UI to create new Cron Jobs (`POST /api/cron/jobs`). -* Form/UI to update Cron Jobs (`PUT /api/cron/jobs/{id}`). - -### Priority 5: Kanban Plugin (46 endpoints) -* Full Kanban task boards, lists, cards, comments, attachments. - -### Priority 6: Other Administrative Tools -* Env vars management, webhooks, system backup/restore, credential storage. +*Other minor backlogs include Skills Hub and Advanced Session management.* diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 6e69901a..91c309a6 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -22,9 +22,11 @@ import com.m57.hermescontrol.data.local.AuthManager import kotlinx.coroutines.launch import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent +import com.m57.hermescontrol.ui.config.ConfigScreen as ConfigScreenContent import com.m57.hermescontrol.ui.connect.ConnectScreen as ConnectScreenContent import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent +import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent @@ -45,7 +47,8 @@ fun MainNavigation() { val gesturesEnabled = currentScreen == ChatScreen || currentScreen == SkillsScreen || currentScreen == CronJobsScreen || currentScreen == GatewayScreen || currentScreen == ProfilesScreen || - currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen + currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen || + currentScreen == PairingScreen || currentScreen == ConfigScreen val openDrawer = { scope.launch { drawerState.open() } } ModalNavigationDrawer( @@ -114,6 +117,24 @@ fun MainNavigation() { }, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) + NavigationDrawerItem( + label = { Text("Pairing") }, + selected = currentScreen == PairingScreen, + onClick = { + scope.launch { drawerState.close() } + NavigationController.navigateTo(PairingScreen) + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + NavigationDrawerItem( + label = { Text("Config") }, + selected = currentScreen == ConfigScreen, + onClick = { + scope.launch { drawerState.close() } + NavigationController.navigateTo(ConfigScreen) + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) NavigationDrawerItem( label = { Text("Gateway") }, selected = currentScreen == GatewayScreen, @@ -189,6 +210,18 @@ fun MainNavigation() { ) } + entry { + PairingScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } + + entry { + ConfigScreenContent( + onOpenDrawer = { openDrawer() }, + ) + } + entry { GatewayScreenContent( onOpenDrawer = { openDrawer() }, diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 6647361c..b6bd2795 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -20,3 +20,7 @@ import kotlinx.serialization.Serializable @Serializable data object ToolsetsScreen : NavKey @Serializable data object AchievementsScreen : NavKey + +@Serializable data object PairingScreen : NavKey + +@Serializable data object ConfigScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Config.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Config.kt new file mode 100644 index 00000000..11ec62c2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Config.kt @@ -0,0 +1,11 @@ +package com.m57.hermescontrol.data.model + +data class RawConfigResponse( + val path: String?, + val yaml: String?, +) + +data class UpdateRawConfigRequest( + val yaml_text: String, + val profile: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Pairing.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Pairing.kt new file mode 100644 index 00000000..ac1bf08a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Pairing.kt @@ -0,0 +1,25 @@ +package com.m57.hermescontrol.data.model + +data class PairingResponse( + val pending: List, + val approved: List, +) + +data class PairingItem( + val platform: String, + val user_id: String?, + val username: String?, + val display_name: String?, + val code: String?, + val created_at: String?, +) + +data class PairingApproveRequest( + val platform: String, + val code: String, +) + +data class PairingRevokeRequest( + val platform: String, + val user_id: String, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 8cd3352f..dc15cd85 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -3,8 +3,12 @@ package com.m57.hermescontrol.data.remote import com.m57.hermescontrol.data.model.AchievementsResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.CronJob +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.model.ProfileSoulResponse import com.m57.hermescontrol.data.model.ProfilesResponse +import com.m57.hermescontrol.data.model.RawConfigResponse import com.m57.hermescontrol.data.model.SessionListResponse import com.m57.hermescontrol.data.model.SessionMessagesResponse import com.m57.hermescontrol.data.model.SetActiveProfileRequest @@ -17,6 +21,7 @@ import com.m57.hermescontrol.data.model.ToolsetToggleRequest import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest import com.m57.hermescontrol.data.model.UpdateProfileModelRequest import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest +import com.m57.hermescontrol.data.model.UpdateRawConfigRequest import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE @@ -128,4 +133,28 @@ interface HermesApiService { @GET("api/plugins/hermes-achievements/achievements") suspend fun getAchievements(): Response + + @GET("api/pairing") + suspend fun getPairing(): Response + + @POST("api/pairing/approve") + suspend fun approvePairing( + @Body body: PairingApproveRequest, + ): Response + + @POST("api/pairing/revoke") + suspend fun revokePairing( + @Body body: PairingRevokeRequest, + ): Response + + @POST("api/pairing/clear-pending") + suspend fun clearPendingPairing(): Response + + @GET("api/config/raw") + suspend fun getRawConfig(): Response + + @PUT("api/config/raw") + suspend fun updateRawConfig( + @Body body: UpdateRawConfigRequest, + ): Response } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt new file mode 100644 index 00000000..6fbdb45c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt @@ -0,0 +1,164 @@ +package com.m57.hermescontrol.ui.config + +import android.widget.Toast +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfigScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + viewModel: ConfigViewModel = viewModel { ConfigViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + var editingText by remember(state.yamlText) { mutableStateOf(state.yamlText ?: "") } + + LaunchedEffect(Unit) { + viewModel.loadRawConfig() + } + + LaunchedEffect(state.toastMessage) { + state.toastMessage?.let { msg -> + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + viewModel.clearToast() + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + if (onOpenDrawer != null) { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Open Drawer", + ) + } + } + }, + title = { Text("Raw Configuration") }, + actions = { + IconButton(onClick = { viewModel.loadRawConfig() }) { + Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") + } + }, + ) + }, + modifier = modifier, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (state.isLoading && state.yamlText == null) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else if (state.errorMessage != null && state.yamlText == null) { + Column( + modifier = + Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadRawConfig() }) { + Text("Retry") + } + } + } else { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + ) { + state.path?.let { + Text( + text = "Path: $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + + OutlinedTextField( + value = editingText, + onValueChange = { editingText = it }, + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + placeholder = { Text("YAML Config...") }, + textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + maxLines = Int.MAX_VALUE, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { editingText = state.yamlText ?: "" }, + modifier = Modifier.weight(1f), + ) { + Text("Reset") + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + onClick = { viewModel.saveRawConfig(editingText) }, + modifier = Modifier.weight(1f), + enabled = !state.isSaving, + ) { + if (state.isSaving) { + CircularProgressIndicator(modifier = Modifier.width(16.dp)) + } else { + Text("Save Config") + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt new file mode 100644 index 00000000..238a5ead --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigViewModel.kt @@ -0,0 +1,101 @@ +package com.m57.hermescontrol.ui.config + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.UpdateRawConfigRequest +import com.m57.hermescontrol.data.remote.ApiClient +import kotlinx.coroutines.Dispatchers +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 ConfigUiState( + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val path: String? = null, + val yamlText: String? = null, + val errorMessage: String? = null, + val toastMessage: String? = null, +) + +class ConfigViewModel : ViewModel() { + private val _uiState = MutableStateFlow(ConfigUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadRawConfig() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.getRawConfig() + } + if (response.isSuccessful) { + _uiState.update { + it.copy( + isLoading = false, + path = response.body()?.path, + yamlText = response.body()?.yaml, + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load config: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load config: ${e.message}", + ) + } + } + } + } + + fun saveRawConfig(yamlText: String) { + _uiState.update { it.copy(isSaving = true) } + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.updateRawConfig(UpdateRawConfigRequest(yamlText)) + } + if (response.isSuccessful) { + _uiState.update { + it.copy( + isSaving = false, + yamlText = yamlText, + toastMessage = "Configuration saved successfully", + ) + } + } else { + _uiState.update { + it.copy( + isSaving = false, + toastMessage = "Failed to save config: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isSaving = false, + toastMessage = "Failed to save config: ${e.message}", + ) + } + } + } + } + + fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt new file mode 100644 index 00000000..ac91171d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingScreen.kt @@ -0,0 +1,271 @@ +package com.m57.hermescontrol.ui.pairing + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Refresh +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.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PairingScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + viewModel: PairingViewModel = viewModel { PairingViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.loadPairing() + } + + LaunchedEffect(state.toastMessage) { + state.toastMessage?.let { msg -> + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + viewModel.clearToast() + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + if (onOpenDrawer != null) { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Filled.Menu, + contentDescription = "Open Drawer", + ) + } + } + }, + title = { Text("Device Pairing") }, + actions = { + IconButton(onClick = { viewModel.loadPairing() }) { + Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh") + } + }, + ) + }, + modifier = modifier, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (state.isLoading && state.pairing == null) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } else if (state.errorMessage != null && state.pairing == null) { + Column( + modifier = + Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadPairing() }) { + Text("Retry") + } + } + } else { + val pairing = state.pairing + val pending = pairing?.pending.orEmpty() + val approved = pairing?.approved.orEmpty() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (pending.isNotEmpty()) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Pending Approvals", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + OutlinedButton( + onClick = { viewModel.clearPending() }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Clear All") + } + } + } + + items(pending) { item -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Platform: ${item.platform.uppercase()}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + item.code?.let { + Text( + text = "Pairing Code: $it", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + + Button( + onClick = { + viewModel.approvePairing(item.platform, item.code ?: "") + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text("Approve") + } + } + } + } + } + + item { + Text( + text = "Approved Devices", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + if (approved.isEmpty()) { + item { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No approved devices paired yet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + items(approved) { item -> + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Platform: ${item.platform.uppercase()}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "User ID: ${item.user_id ?: "Unknown"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + item.username?.let { + Text( + text = "Username: @$it", + style = MaterialTheme.typography.bodySmall, + ) + } + } + + OutlinedButton( + onClick = { + viewModel.revokePairing(item.platform, item.user_id ?: "") + }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Revoke") + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt new file mode 100644 index 00000000..98cbd3f0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/pairing/PairingViewModel.kt @@ -0,0 +1,120 @@ +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 kotlinx.coroutines.Dispatchers +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 isActionRunning: Boolean = false, + val pairing: PairingResponse? = null, + val errorMessage: String? = null, + val toastMessage: String? = null, +) + +class PairingViewModel : ViewModel() { + private val _uiState = MutableStateFlow(PairingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadPairing() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + try { + val response = + withContext(Dispatchers.IO) { + ApiClient.hermesApi.getPairing() + } + if (response.isSuccessful) { + _uiState.update { it.copy(isLoading = false, pairing = response.body()) } + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load pairing info: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load pairing info: ${e.message}", + ) + } + } + } + } + + fun approvePairing( + platform: String, + code: String, + ) { + runAction("Approve") { + ApiClient.hermesApi.approvePairing(PairingApproveRequest(platform, code)) + } + } + + fun revokePairing( + platform: String, + userId: String, + ) { + runAction("Revoke") { + ApiClient.hermesApi.revokePairing(PairingRevokeRequest(platform, userId)) + } + } + + fun clearPending() { + runAction("Clear Pending") { + ApiClient.hermesApi.clearPendingPairing() + } + } + + private fun runAction( + actionName: String, + apiCall: suspend () -> retrofit2.Response, + ) { + _uiState.update { it.copy(isActionRunning = true) } + viewModelScope.launch { + try { + val response = withContext(Dispatchers.IO) { apiCall() } + if (response.isSuccessful) { + _uiState.update { + it.copy( + isActionRunning = false, + toastMessage = "Pairing action '$actionName' succeeded", + ) + } + loadPairing() + } else { + _uiState.update { + it.copy( + isActionRunning = false, + toastMessage = "Action '$actionName' failed: HTTP ${response.code()}", + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isActionRunning = false, + toastMessage = "Action '$actionName' failed: ${e.message}", + ) + } + } + } + } + + fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } +}