* 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:
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) {
|
||||
|
||||
Reference in new issue
Block a user