feat(#471): implement DataStore-based server configuration and self-h… (#477)

* feat(#471): implement DataStore-based server configuration and self-healing

* refactor(#471): address Sourcery feedback on ANR risk and reset method splitting

* refactor(#471): implement synchronous ServerStore initialization to fully remove ANR risk

* feat(#471): show profile name, host and port on add profile, redirect to login on save, and support shortcut login buttons for existing profiles
This commit is contained in:
M57 authored and GitHub committed 2026-07-06 22:40:44 +03:00
1 parent 5697405b19
commit 430a083647
20 files changed
+774 -288

No files matched your search

@@ -18,11 +18,13 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -126,6 +128,31 @@ fun AuthLoginScreen(
Spacer(modifier = Modifier.height(8.dp))
if (state.loggedInProfiles.isNotEmpty()) {
Text(
text = stringResource(R.string.auth_login_existing_profiles_title),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 8.dp),
)
state.loggedInProfiles.forEach { profile ->
androidx.compose.material3.OutlinedButton(
onClick = { viewModel.useExistingProfile(profile.id) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.auth_login_use_profile, profile.name))
}
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(8.dp))
}
// Host field
OutlinedTextField(
value = state.host,
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
@@ -47,14 +48,42 @@ data class AuthLoginUiState(
val authMode: DashboardAuthMode? = null,
val connectionSuccess: Boolean = false,
val errorMessage: String? = null,
val loggedInProfiles: List<ConnectionProfile> = emptyList(),
)
class AuthLoginViewModel(
private val app: Application,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthLoginUiState())
private val _uiState =
MutableStateFlow(
AuthLoginUiState(
host = AuthManager.getHost(),
port = AuthManager.getPort().toString(),
),
)
val uiState: StateFlow<AuthLoginUiState> = _uiState.asStateFlow()
init {
loadLoggedInProfiles()
}
fun loadLoggedInProfiles() {
val allProfiles = AuthManager.getConnectionProfiles()
val loggedIn =
allProfiles.filter { profile ->
val token = AuthManager.getProfileToken(profile.id)
!token.isNullOrBlank()
}
_uiState.update { it.copy(loggedInProfiles = loggedIn) }
}
fun useExistingProfile(profileId: String) {
AuthManager.setSelectedProfileId(profileId)
ApiClient.rebuild()
HermesWsClient.connect()
_uiState.update { it.copy(connectionSuccess = true) }
}
companion object {
private const val TAG = "AuthLoginVM"
}
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
@@ -30,8 +31,8 @@ data class ConnectUiState(
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,
val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfile: ConnectionProfile? = null,
)
class ConnectViewModel(
@@ -76,7 +77,7 @@ class ConnectViewModel(
_uiState.update { it.copy(saveProfile = value) }
}
fun selectProfile(profile: com.m57.hermescontrol.data.model.ConnectionProfile?) {
fun selectProfile(profile: ConnectionProfile?) {
if (profile == null) {
AuthManager.setSelectedProfileId(null)
_uiState.update {
@@ -160,7 +161,7 @@ class ConnectViewModel(
if (existingIndex >= 0) {
currentProfiles[existingIndex].copy(host = state.host, port = port)
} else {
com.m57.hermescontrol.data.model.ConnectionProfile(
ConnectionProfile(
name = state.profileName,
host = state.host,
port = port,
@@ -51,6 +51,7 @@ import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
@@ -84,11 +85,20 @@ enum class SettingsTab {
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onNavigateToLogin: () -> Unit = {},
onLogout: () -> Unit = {},
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.navigateToLogin) {
if (state.navigateToLogin) {
onNavigateToLogin()
viewModel.clearNavigateToLogin()
}
}
var passwordVisible by remember { mutableStateOf(false) }
var selectedTab by remember { mutableStateOf(SettingsTab.CONNECTION) }
@@ -615,19 +625,21 @@ private fun ProfileEditorDialog(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = token,
onValueChange = onTokenChange,
label = { Text(stringResource(R.string.settings_field_token)) },
singleLine = true,
visualTransformation =
if (token.isNotEmpty()) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
modifier = Modifier.fillMaxWidth(),
)
if (isEditing) {
OutlinedTextField(
value = token,
onValueChange = onTokenChange,
label = { Text(stringResource(R.string.settings_field_token)) },
singleLine = true,
visualTransformation =
if (token.isNotEmpty()) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
modifier = Modifier.fillMaxWidth(),
)
}
}
},
confirmButton = {
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.ScreenRegistry
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -34,7 +35,7 @@ data class SettingsUiState(
val selectedNavItems: List<String> = emptyList(),
val typingEffectEnabled: Boolean = false,
val typingEffectDelayMs: Int = 30,
val profiles: List<com.m57.hermescontrol.data.model.ConnectionProfile> = emptyList(),
val profiles: List<ConnectionProfile> = emptyList(),
val selectedProfileId: String? = null,
val renameProfileName: String = "",
val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT,
@@ -49,6 +50,7 @@ data class SettingsUiState(
val showDeleteConfirm: Boolean = false,
val profileToDeleteId: String? = null,
val profileToDeleteName: String = "",
val navigateToLogin: Boolean = false,
)
class SettingsViewModel(
@@ -209,14 +211,16 @@ class SettingsViewModel(
} else {
// Add new profile
val newProfile =
com.m57.hermescontrol.data.model.ConnectionProfile(
ConnectionProfile(
name = name,
host = host,
port = port,
)
profiles.add(newProfile)
AuthManager.saveConnectionProfiles(profiles)
AuthManager.setProfileToken(newProfile.id, token)
AuthManager.setProfileToken(newProfile.id, "")
AuthManager.setSelectedProfileId(newProfile.id)
_uiState.update { it.copy(navigateToLogin = true) }
}
closeProfileDialog()
@@ -224,6 +228,10 @@ class SettingsViewModel(
ApiClient.rebuild()
}
fun clearNavigateToLogin() {
_uiState.update { it.copy(navigateToLogin = false) }
}
// ── Delete confirmation ──────────────────────────────────────────────
fun requestDeleteProfile(profileId: String) {