* feat(#107): implement saved connection profiles support * fix(connect): fallback profiles resolution and stubbing in tests
This commit is contained in:
8 files changed
+475
-9
No files matched your search
@@ -26,6 +26,8 @@ object AuthManager {
|
|||||||
private const val KEY_BOTTOM_NAV_ITEMS = "bottom_nav_items"
|
private const val KEY_BOTTOM_NAV_ITEMS = "bottom_nav_items"
|
||||||
private const val KEY_TYPING_EFFECT_ENABLED = "typing_effect_enabled"
|
private const val KEY_TYPING_EFFECT_ENABLED = "typing_effect_enabled"
|
||||||
private const val KEY_TYPING_EFFECT_DELAY_MS = "typing_effect_delay_ms"
|
private const val KEY_TYPING_EFFECT_DELAY_MS = "typing_effect_delay_ms"
|
||||||
|
private const val KEY_CONNECTION_PROFILES = "connection_profiles"
|
||||||
|
private const val KEY_SELECTED_PROFILE_ID = "selected_profile_id"
|
||||||
|
|
||||||
private const val DEFAULT_HOST = "127.0.0.1"
|
private const val DEFAULT_HOST = "127.0.0.1"
|
||||||
private const val DEFAULT_PORT = 9119
|
private const val DEFAULT_PORT = 9119
|
||||||
@@ -39,6 +41,8 @@ object AuthManager {
|
|||||||
private val _bottomNavItemsFlow = MutableStateFlow<List<String>>(emptyList())
|
private val _bottomNavItemsFlow = MutableStateFlow<List<String>>(emptyList())
|
||||||
val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow()
|
val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow()
|
||||||
|
|
||||||
|
private val gson = com.google.gson.Gson()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise the encrypted preferences.
|
* Initialise the encrypted preferences.
|
||||||
* Call this once from Application.onCreate() or MainActivity.onCreate().
|
* Call this once from Application.onCreate() or MainActivity.onCreate().
|
||||||
@@ -71,29 +75,113 @@ object AuthManager {
|
|||||||
"AuthManager not initialised – call AuthManager.init(context) first",
|
"AuthManager not initialised – call AuthManager.init(context) first",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Connection Profiles ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun getConnectionProfiles(): List<com.m57.hermescontrol.data.model.ConnectionProfile> {
|
||||||
|
val json = requirePrefs().getString(KEY_CONNECTION_PROFILES, null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val type =
|
||||||
|
object : com.google.gson.reflect.TypeToken<
|
||||||
|
List<com.m57.hermescontrol.data.model.ConnectionProfile>,
|
||||||
|
>() {}.type
|
||||||
|
gson.fromJson(json, type) ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveConnectionProfiles(profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile>) {
|
||||||
|
val json = gson.toJson(profiles)
|
||||||
|
requirePrefs().edit().putString(KEY_CONNECTION_PROFILES, json).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getProfileToken(profileId: String): String? {
|
||||||
|
return requirePrefs().getString("token_$profileId", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setProfileToken(
|
||||||
|
profileId: String,
|
||||||
|
token: String?,
|
||||||
|
) {
|
||||||
|
requirePrefs().edit().putString("token_$profileId", token).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSelectedProfileId(): String? {
|
||||||
|
val id = requirePrefs().getString(KEY_SELECTED_PROFILE_ID, null)
|
||||||
|
return if (id.isNullOrBlank()) null else id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSelectedProfileId(id: String?) {
|
||||||
|
requirePrefs().edit().putString(KEY_SELECTED_PROFILE_ID, id).apply()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Token ────────────────────────────────────────────────────────────
|
// ── Token ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun getToken(): String? = requirePrefs().getString(KEY_TOKEN, null)
|
fun getToken(): String? {
|
||||||
|
val selectedId = getSelectedProfileId()
|
||||||
|
if (selectedId != null) {
|
||||||
|
val token = getProfileToken(selectedId)
|
||||||
|
if (token != null) return token
|
||||||
|
}
|
||||||
|
return requirePrefs().getString(KEY_TOKEN, null)
|
||||||
|
}
|
||||||
|
|
||||||
fun setToken(token: String?) {
|
fun setToken(token: String?) {
|
||||||
|
val selectedId = getSelectedProfileId()
|
||||||
|
if (selectedId != null) {
|
||||||
|
setProfileToken(selectedId, token)
|
||||||
|
} else {
|
||||||
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
|
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Host ─────────────────────────────────────────────────────────────
|
// ── Host ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun getHost(): String = requirePrefs().getString(KEY_HOST, DEFAULT_HOST) ?: DEFAULT_HOST
|
fun getHost(): String {
|
||||||
|
val selectedId = getSelectedProfileId()
|
||||||
|
if (selectedId != null) {
|
||||||
|
val profile = getConnectionProfiles().firstOrNull { it.id == selectedId }
|
||||||
|
if (profile != null) return profile.host
|
||||||
|
}
|
||||||
|
return requirePrefs().getString(KEY_HOST, DEFAULT_HOST) ?: DEFAULT_HOST
|
||||||
|
}
|
||||||
|
|
||||||
fun setHost(host: String) {
|
fun setHost(host: String) {
|
||||||
|
val selectedId = getSelectedProfileId()
|
||||||
|
if (selectedId != null) {
|
||||||
|
val profiles =
|
||||||
|
getConnectionProfiles().map {
|
||||||
|
if (it.id == selectedId) it.copy(host = host) else it
|
||||||
|
}
|
||||||
|
saveConnectionProfiles(profiles)
|
||||||
|
} else {
|
||||||
requirePrefs().edit().putString(KEY_HOST, host).apply()
|
requirePrefs().edit().putString(KEY_HOST, host).apply()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Port ─────────────────────────────────────────────────────────────
|
// ── Port ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun getPort(): Int = requirePrefs().getInt(KEY_PORT, DEFAULT_PORT)
|
fun getPort(): Int {
|
||||||
|
val selectedId = getSelectedProfileId()
|
||||||
|
if (selectedId != null) {
|
||||||
|
val profile = getConnectionProfiles().firstOrNull { it.id == selectedId }
|
||||||
|
if (profile != null) return profile.port
|
||||||
|
}
|
||||||
|
return requirePrefs().getInt(KEY_PORT, DEFAULT_PORT)
|
||||||
|
}
|
||||||
|
|
||||||
fun setPort(port: Int) {
|
fun setPort(port: Int) {
|
||||||
|
val selectedId = getSelectedProfileId()
|
||||||
|
if (selectedId != null) {
|
||||||
|
val profiles =
|
||||||
|
getConnectionProfiles().map {
|
||||||
|
if (it.id == selectedId) it.copy(port = port) else it
|
||||||
|
}
|
||||||
|
saveConnectionProfiles(profiles)
|
||||||
|
} else {
|
||||||
requirePrefs().edit().putInt(KEY_PORT, port).apply()
|
requirePrefs().edit().putInt(KEY_PORT, port).apply()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auto-reconnect ───────────────────────────────────────────────────
|
// ── Auto-reconnect ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class ConnectionProfile(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val name: String,
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
)
|
||||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -23,11 +24,15 @@ import androidx.compose.material.icons.filled.Bolt
|
|||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -118,6 +123,48 @@ fun ConnectScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Connection Profiles Dropdown / Selection
|
||||||
|
if (state.profiles.isNotEmpty()) {
|
||||||
|
var profilesExpanded by remember { mutableStateOf(false) }
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { profilesExpanded = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (state.selectedProfile != null) {
|
||||||
|
"Profile: ${state.selectedProfile!!.name}"
|
||||||
|
} else {
|
||||||
|
"Select Saved Profile"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = profilesExpanded,
|
||||||
|
onDismissRequest = { profilesExpanded = false },
|
||||||
|
modifier = Modifier.fillMaxWidth(0.85f),
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("None (Standalone)") },
|
||||||
|
onClick = {
|
||||||
|
viewModel.selectProfile(null)
|
||||||
|
profilesExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
state.profiles.forEach { profile ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(profile.name) },
|
||||||
|
onClick = {
|
||||||
|
viewModel.selectProfile(profile)
|
||||||
|
profilesExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pairing (primary path)
|
// Pairing (primary path)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -157,6 +204,34 @@ fun ConnectScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profile Save Options prior to connecting
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = state.saveProfile,
|
||||||
|
onCheckedChange = viewModel::onSaveProfileChange,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Save connection profile",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = state.saveProfile) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.profileName,
|
||||||
|
onValueChange = viewModel::onProfileNameChange,
|
||||||
|
label = { Text("Profile Name") },
|
||||||
|
placeholder = { Text("e.g. Local Hermit, Remote Server") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::connect,
|
onClick = viewModel::connect,
|
||||||
enabled = !state.isConnecting,
|
enabled = !state.isConnecting,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ data class ConnectUiState(
|
|||||||
val isConnecting: Boolean = false,
|
val isConnecting: Boolean = false,
|
||||||
val connectionSuccess: Boolean = false,
|
val connectionSuccess: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
|
val profileName: String = "",
|
||||||
|
val saveProfile: Boolean = false,
|
||||||
|
val profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile> = emptyList(),
|
||||||
|
val selectedProfile: com.m57.hermescontrol.data.model.ConnectionProfile? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ConnectViewModel : ViewModel() {
|
class ConnectViewModel : ViewModel() {
|
||||||
@@ -29,19 +33,63 @@ class ConnectViewModel : ViewModel() {
|
|||||||
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Load saved values
|
loadSavedValues()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSavedValues() {
|
||||||
val savedToken = AuthManager.getToken() ?: ""
|
val savedToken = AuthManager.getToken() ?: ""
|
||||||
val savedHost = AuthManager.getHost()
|
val savedHost = AuthManager.getHost()
|
||||||
val savedPort = AuthManager.getPort()
|
val savedPort = AuthManager.getPort()
|
||||||
|
val profiles = AuthManager.getConnectionProfiles()
|
||||||
|
val selectedId = AuthManager.getSelectedProfileId()
|
||||||
|
val selectedProfile = profiles.firstOrNull { it.id == selectedId }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
token = savedToken,
|
token = savedToken,
|
||||||
host = savedHost,
|
host = savedHost,
|
||||||
port = savedPort.toString(),
|
port = savedPort.toString(),
|
||||||
|
profiles = profiles,
|
||||||
|
selectedProfile = selectedProfile,
|
||||||
|
profileName = selectedProfile?.name ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onProfileNameChange(value: String) {
|
||||||
|
_uiState.update { it.copy(profileName = value, errorMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSaveProfileChange(value: Boolean) {
|
||||||
|
_uiState.update { it.copy(saveProfile = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectProfile(profile: com.m57.hermescontrol.data.model.ConnectionProfile?) {
|
||||||
|
if (profile == null) {
|
||||||
|
AuthManager.setSelectedProfileId(null)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
selectedProfile = null,
|
||||||
|
profileName = "",
|
||||||
|
token = "",
|
||||||
|
host = "127.0.0.1",
|
||||||
|
port = "9119",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AuthManager.setSelectedProfileId(profile.id)
|
||||||
|
val token = AuthManager.getProfileToken(profile.id) ?: ""
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
selectedProfile = profile,
|
||||||
|
profileName = profile.name,
|
||||||
|
host = profile.host,
|
||||||
|
port = profile.port.toString(),
|
||||||
|
token = token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onTokenChange(value: String) {
|
fun onTokenChange(value: String) {
|
||||||
_uiState.update { it.copy(token = value.trim(), errorMessage = null) }
|
_uiState.update { it.copy(token = value.trim(), errorMessage = null) }
|
||||||
}
|
}
|
||||||
@@ -72,19 +120,54 @@ class ConnectViewModel : ViewModel() {
|
|||||||
|
|
||||||
_uiState.update { it.copy(isConnecting = true, errorMessage = null) }
|
_uiState.update { it.copy(isConnecting = true, errorMessage = null) }
|
||||||
|
|
||||||
// Save settings before testing so ApiClient uses the right base URL
|
viewModelScope.launch {
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// Update ApiClient temporarily with current inputs to verify connection
|
||||||
AuthManager.setToken(state.token)
|
AuthManager.setToken(state.token)
|
||||||
AuthManager.setHost(state.host)
|
AuthManager.setHost(state.host)
|
||||||
AuthManager.setPort(port)
|
AuthManager.setPort(port)
|
||||||
ApiClient.rebuild()
|
ApiClient.rebuild()
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
val result =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
safeApiCall { ApiClient.hermesApi.getStatus() }
|
safeApiCall { ApiClient.hermesApi.getStatus() }
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
|
if (state.saveProfile) {
|
||||||
|
val currentProfiles = AuthManager.getConnectionProfiles()
|
||||||
|
val existingIndex =
|
||||||
|
currentProfiles.indexOfFirst {
|
||||||
|
it.name.equals(
|
||||||
|
state.profileName,
|
||||||
|
ignoreCase = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val targetProfile =
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
currentProfiles[existingIndex].copy(host = state.host, port = port)
|
||||||
|
} else {
|
||||||
|
com.m57.hermescontrol.data.model.ConnectionProfile(
|
||||||
|
name = state.profileName,
|
||||||
|
host = state.host,
|
||||||
|
port = port,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val updatedProfiles =
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
currentProfiles.mapIndexed { idx, p -> if (idx == existingIndex) targetProfile else p }
|
||||||
|
} else {
|
||||||
|
currentProfiles + targetProfile
|
||||||
|
}
|
||||||
|
AuthManager.saveConnectionProfiles(updatedProfiles)
|
||||||
|
AuthManager.setSelectedProfileId(targetProfile.id)
|
||||||
|
AuthManager.setProfileToken(targetProfile.id, state.token)
|
||||||
|
} else {
|
||||||
|
// Clear selected profile if we connected standalone and didn't check "save profile"
|
||||||
|
AuthManager.setSelectedProfileId(null)
|
||||||
|
AuthManager.setToken(state.token)
|
||||||
|
AuthManager.setHost(state.host)
|
||||||
|
AuthManager.setPort(port)
|
||||||
|
}
|
||||||
|
ApiClient.rebuild()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null)
|
it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,86 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
// Connection section
|
// Connection section
|
||||||
SectionCard(title = "Connection") {
|
SectionCard(title = "Connection") {
|
||||||
|
// Connection Profiles management in Settings
|
||||||
|
if (state.profiles.isNotEmpty()) {
|
||||||
|
var profilesExpanded by remember { mutableStateOf(false) }
|
||||||
|
Text(
|
||||||
|
text = "Connection Profile",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { profilesExpanded = true },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
val activeProfile = state.profiles.firstOrNull { it.id == state.selectedProfileId }
|
||||||
|
Text(text = activeProfile?.name ?: "Standalone / Default")
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = profilesExpanded,
|
||||||
|
onDismissRequest = { profilesExpanded = false },
|
||||||
|
modifier = Modifier.fillMaxWidth(0.85f),
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("None (Standalone / Default)") },
|
||||||
|
onClick = {
|
||||||
|
viewModel.selectProfile(null)
|
||||||
|
profilesExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
state.profiles.forEach { profile ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(profile.name)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.deleteProfile(profile.id)
|
||||||
|
profilesExpanded = false
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = "Delete Profile",
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
viewModel.selectProfile(profile.id)
|
||||||
|
profilesExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.selectedProfileId != null) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.renameProfileName,
|
||||||
|
onValueChange = viewModel::onRenameProfileNameChange,
|
||||||
|
label = { Text("Rename Profile") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
trailingIcon = {
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::renameProfile,
|
||||||
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
|
) {
|
||||||
|
Text("Rename")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.host,
|
value = state.host,
|
||||||
onValueChange = viewModel::onHostChange,
|
onValueChange = viewModel::onHostChange,
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ data class SettingsUiState(
|
|||||||
val selectedNavItems: List<String> = emptyList(),
|
val selectedNavItems: List<String> = emptyList(),
|
||||||
val typingEffectEnabled: Boolean = false,
|
val typingEffectEnabled: Boolean = false,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
|
val profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile> = emptyList(),
|
||||||
|
val selectedProfileId: String? = null,
|
||||||
|
val renameProfileName: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
class SettingsViewModel : ViewModel() {
|
class SettingsViewModel : ViewModel() {
|
||||||
@@ -38,6 +41,7 @@ class SettingsViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun loadSettings() {
|
private fun loadSettings() {
|
||||||
|
val selectedId = AuthManager.getSelectedProfileId()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
host = AuthManager.getHost(),
|
host = AuthManager.getHost(),
|
||||||
@@ -51,10 +55,50 @@ class SettingsViewModel : ViewModel() {
|
|||||||
selectedNavItems = AuthManager.getBottomNavItems(),
|
selectedNavItems = AuthManager.getBottomNavItems(),
|
||||||
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
typingEffectEnabled = AuthManager.isTypingEffectEnabled(),
|
||||||
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
|
typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(),
|
||||||
|
profiles = AuthManager.getConnectionProfiles(),
|
||||||
|
selectedProfileId = selectedId,
|
||||||
|
renameProfileName =
|
||||||
|
AuthManager.getConnectionProfiles().firstOrNull {
|
||||||
|
p ->
|
||||||
|
p.id == selectedId
|
||||||
|
}?.name ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun selectProfile(profileId: String?) {
|
||||||
|
AuthManager.setSelectedProfileId(profileId)
|
||||||
|
loadSettings()
|
||||||
|
ApiClient.rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRenameProfileNameChange(value: String) {
|
||||||
|
_uiState.update { it.copy(renameProfileName = value, isSaved = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renameProfile() {
|
||||||
|
val currentId = _uiState.value.selectedProfileId ?: return
|
||||||
|
val newName = _uiState.value.renameProfileName.trim()
|
||||||
|
if (newName.isBlank()) return
|
||||||
|
val updatedProfiles =
|
||||||
|
AuthManager.getConnectionProfiles().map {
|
||||||
|
if (it.id == currentId) it.copy(name = newName) else it
|
||||||
|
}
|
||||||
|
AuthManager.saveConnectionProfiles(updatedProfiles)
|
||||||
|
loadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteProfile(profileId: String) {
|
||||||
|
val updatedProfiles = AuthManager.getConnectionProfiles().filter { it.id != profileId }
|
||||||
|
AuthManager.saveConnectionProfiles(updatedProfiles)
|
||||||
|
AuthManager.setProfileToken(profileId, null)
|
||||||
|
if (AuthManager.getSelectedProfileId() == profileId) {
|
||||||
|
AuthManager.setSelectedProfileId(null)
|
||||||
|
}
|
||||||
|
loadSettings()
|
||||||
|
ApiClient.rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
fun onHostChange(value: String) {
|
fun onHostChange(value: String) {
|
||||||
_uiState.update { it.copy(host = value.trim(), isSaved = false) }
|
_uiState.update { it.copy(host = value.trim(), isSaved = false) }
|
||||||
}
|
}
|
||||||
@@ -145,6 +189,7 @@ class SettingsViewModel : ViewModel() {
|
|||||||
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs)
|
||||||
ApiClient.rebuild()
|
ApiClient.rebuild()
|
||||||
|
|
||||||
|
loadSettings()
|
||||||
_uiState.update { it.copy(isSaved = true, testResult = null) }
|
_uiState.update { it.copy(isSaved = true, testResult = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ class E2eIntegrationTest {
|
|||||||
every { AuthManager.setToken(any()) } returns Unit
|
every { AuthManager.setToken(any()) } returns Unit
|
||||||
every { AuthManager.setHost(any()) } returns Unit
|
every { AuthManager.setHost(any()) } returns Unit
|
||||||
every { AuthManager.setPort(any()) } returns Unit
|
every { AuthManager.setPort(any()) } returns Unit
|
||||||
|
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
||||||
|
every { AuthManager.getSelectedProfileId() } returns null
|
||||||
|
every { AuthManager.setSelectedProfileId(any()) } returns Unit
|
||||||
|
every { AuthManager.getProfileToken(any()) } returns null
|
||||||
|
every { AuthManager.setProfileToken(any(), any()) } returns Unit
|
||||||
|
every { AuthManager.saveConnectionProfiles(any()) } returns Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ class ConnectViewModelTest {
|
|||||||
every { AuthManager.setToken(any()) } returns Unit
|
every { AuthManager.setToken(any()) } returns Unit
|
||||||
every { AuthManager.setHost(any()) } returns Unit
|
every { AuthManager.setHost(any()) } returns Unit
|
||||||
every { AuthManager.setPort(any()) } returns Unit
|
every { AuthManager.setPort(any()) } returns Unit
|
||||||
|
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
||||||
|
every { AuthManager.saveConnectionProfiles(any()) } returns Unit
|
||||||
|
every { AuthManager.getProfileToken(any()) } returns null
|
||||||
|
every { AuthManager.setProfileToken(any(), any()) } returns Unit
|
||||||
|
every { AuthManager.getSelectedProfileId() } returns null
|
||||||
|
every { AuthManager.setSelectedProfileId(any()) } returns Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -322,4 +328,77 @@ class ConnectViewModelTest {
|
|||||||
assertEquals(validToken, state.token)
|
assertEquals(validToken, state.token)
|
||||||
assertNull(state.errorMessage)
|
assertNull(state.errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSelectProfile_updatesState() {
|
||||||
|
val profile =
|
||||||
|
com.m57.hermescontrol.data.model.ConnectionProfile(
|
||||||
|
id = "test-id",
|
||||||
|
name = "Test Profile",
|
||||||
|
host = "192.168.1.100",
|
||||||
|
port = 8080,
|
||||||
|
)
|
||||||
|
every { AuthManager.getProfileToken("test-id") } returns "profile-token"
|
||||||
|
|
||||||
|
val viewModel = ConnectViewModel()
|
||||||
|
viewModel.selectProfile(profile)
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertEquals("Test Profile", state.profileName)
|
||||||
|
assertEquals("192.168.1.100", state.host)
|
||||||
|
assertEquals("8080", state.port)
|
||||||
|
assertEquals("profile-token", state.token)
|
||||||
|
assertEquals(profile, state.selectedProfile)
|
||||||
|
|
||||||
|
verify { AuthManager.setSelectedProfileId("test-id") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSelectProfile_null_clearsState() {
|
||||||
|
val viewModel = ConnectViewModel()
|
||||||
|
viewModel.selectProfile(null)
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertEquals("", state.profileName)
|
||||||
|
assertEquals("127.0.0.1", state.host)
|
||||||
|
assertEquals("9119", state.port)
|
||||||
|
assertEquals("", state.token)
|
||||||
|
assertNull(state.selectedProfile)
|
||||||
|
|
||||||
|
verify { AuthManager.setSelectedProfileId(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testConnect_saveProfile_savesAndSelects() =
|
||||||
|
runTest {
|
||||||
|
val viewModel = ConnectViewModel()
|
||||||
|
viewModel.onTokenChange("valid-token")
|
||||||
|
viewModel.onHostChange("127.0.0.1")
|
||||||
|
viewModel.onPortChange("9119")
|
||||||
|
viewModel.onProfileNameChange("New Profile")
|
||||||
|
viewModel.onSaveProfileChange(true)
|
||||||
|
|
||||||
|
val mockResponse = mockk<Response<StatusResponse>>()
|
||||||
|
every { mockResponse.isSuccessful } returns true
|
||||||
|
every { mockResponse.body() } returns
|
||||||
|
StatusResponse(
|
||||||
|
version = "1.0",
|
||||||
|
gateway_running = true,
|
||||||
|
active_sessions = 1,
|
||||||
|
auth_required = false,
|
||||||
|
gateway_platforms = emptyMap(),
|
||||||
|
)
|
||||||
|
coEvery { mockApiService.getStatus() } returns mockResponse
|
||||||
|
|
||||||
|
viewModel.connect()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify { AuthManager.saveConnectionProfiles(any()) }
|
||||||
|
verify { AuthManager.setSelectedProfileId(any()) }
|
||||||
|
verify { AuthManager.setProfileToken(any(), "valid-token") }
|
||||||
|
verify { ApiClient.rebuild() }
|
||||||
|
|
||||||
|
val state = viewModel.uiState.value
|
||||||
|
assertTrue(state.connectionSuccess)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user