feat: add provider login flow #4

Merged
KremeCN merged 26 commits from feat/provider-login into main 2026-08-25 01:59:00 +08:00
5 changed files with 74 additions and 12 deletions
Showing only changes of commit d893a74bec - Show all commits
@@ -55,11 +55,13 @@ fun LoginRoute(
onDispose(viewModel::clearSensitiveState) onDispose(viewModel::clearSensitiveState)
} }
val state = viewModel.uiState.collectAsStateWithLifecycle().value val state = viewModel.uiState.collectAsStateWithLifecycle().value
val errorText = state.errorMessage?.let { stringResource(it.stringResource()) }
?: actionError
?: bootstrapError
LoginScreen( LoginScreen(
profile = profile, profile = profile,
state = state.copy( state = state,
errorMessage = state.errorMessage ?: actionError ?: bootstrapError, errorText = errorText,
),
onUsernameChange = viewModel::setUsername, onUsernameChange = viewModel::setUsername,
onPasswordChange = viewModel::setPassword, onPasswordChange = viewModel::setPassword,
onRememberUsernameChange = viewModel::setRememberUsername, onRememberUsernameChange = viewModel::setRememberUsername,
@@ -75,6 +77,7 @@ fun LoginRoute(
fun LoginScreen( fun LoginScreen(
profile: ProviderProfile, profile: ProviderProfile,
state: LoginUiState, state: LoginUiState,
errorText: String? = null,
onUsernameChange: (String) -> Unit, onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit, onPasswordChange: (String) -> Unit,
onRememberUsernameChange: (Boolean) -> Unit, onRememberUsernameChange: (Boolean) -> Unit,
@@ -174,7 +177,7 @@ fun LoginScreen(
enabled = formEnabled, enabled = formEnabled,
onCheckedChange = onAutoLoginChange, onCheckedChange = onAutoLoginChange,
) )
state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) } errorText?.let { Text(it, color = MaterialTheme.colorScheme.error) }
ABoxButton( ABoxButton(
onClick = onSubmit, onClick = onSubmit,
enabled = formEnabled && !switchingProvider, enabled = formEnabled && !switchingProvider,
@@ -210,6 +213,15 @@ private fun ToggleRow(
} }
} }
private fun LoginErrorMessage.stringResource(): Int = when (this) {
LoginErrorMessage.REQUIRED_FIELDS -> R.string.login_error_required
LoginErrorMessage.INVALID_CREDENTIALS -> R.string.login_error_invalid_credentials
LoginErrorMessage.NETWORK_UNAVAILABLE -> R.string.login_error_network
LoginErrorMessage.SESSION_EXPIRED -> R.string.login_error_session_expired
LoginErrorMessage.PREFERENCES_UNAVAILABLE -> R.string.login_error_preferences
LoginErrorMessage.LOGIN_FAILED -> R.string.login_error_failed
}
@Composable @Composable
private fun providerName(type: ProviderType): String = stringResource( private fun providerName(type: ProviderType): String = stringResource(
when (type) { when (type) {
@@ -71,7 +71,7 @@ class LoginViewModel @Inject constructor(
if (profile?.id == value.id) { if (profile?.id == value.id) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoadingPreferences = false, isLoadingPreferences = false,
errorMessage = "Unable to load saved login settings", errorMessage = LoginErrorMessage.PREFERENCES_UNAVAILABLE,
) )
} }
} finally { } finally {
@@ -126,7 +126,7 @@ class LoginViewModel @Inject constructor(
state.isSubmitting || state.isSubmitting ||
state.isLoadingPreferences state.isLoadingPreferences
) { ) {
_uiState.value = state.copy(errorMessage = "Username and password are required") _uiState.value = state.copy(errorMessage = LoginErrorMessage.REQUIRED_FIELDS)
return return
} }
submitJob = viewModelScope.launch { submitJob = viewModelScope.launch {
@@ -149,14 +149,23 @@ class LoginViewModel @Inject constructor(
} }
} }
private fun DomainError.toUiMessage(): String = when (this) { private fun DomainError.toUiMessage(): LoginErrorMessage = when (this) {
is DomainError.Authentication -> "Invalid username or password" is DomainError.Authentication -> LoginErrorMessage.INVALID_CREDENTIALS
is DomainError.Network -> "Network unavailable" is DomainError.Network -> LoginErrorMessage.NETWORK_UNAVAILABLE
is DomainError.TokenInvalid -> "Session expired" is DomainError.TokenInvalid -> LoginErrorMessage.SESSION_EXPIRED
else -> detail ?: "Login failed" else -> LoginErrorMessage.LOGIN_FAILED
} }
} }
enum class LoginErrorMessage {
REQUIRED_FIELDS,
INVALID_CREDENTIALS,
NETWORK_UNAVAILABLE,
SESSION_EXPIRED,
PREFERENCES_UNAVAILABLE,
LOGIN_FAILED,
}
data class LoginUiState( data class LoginUiState(
val username: String = "", val username: String = "",
val password: String = "", val password: String = "",
@@ -164,5 +173,5 @@ data class LoginUiState(
val autoLogin: Boolean = false, val autoLogin: Boolean = false,
val isLoadingPreferences: Boolean = false, val isLoadingPreferences: Boolean = false,
val isSubmitting: Boolean = false, val isSubmitting: Boolean = false,
val errorMessage: String? = null, val errorMessage: LoginErrorMessage? = null,
) )
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -60,6 +62,33 @@ class AppSessionViewModel @Inject constructor(
) )
} }
} }
viewModelScope.launch {
coordinator.observeActiveSession()
.map { it?.profileId }
.distinctUntilChanged()
.collect { sessionProfileId ->
if (sessionProfileId != null && bootstrapResult.value?.profileId == sessionProfileId) {
bootstrapResult.value = ProviderBootstrapResult(
profileId = sessionProfileId,
result = DomainResult.Success(null),
)
}
}
}
viewModelScope.launch {
profileStore.observeActiveProfile()
.map { it?.id }
.distinctUntilChanged()
.collect { activeProfileId ->
val currentBootstrap = bootstrapResult.value
if (currentBootstrap != null && currentBootstrap.profileId != activeProfileId) {
bootstrapResult.value = ProviderBootstrapResult(
profileId = activeProfileId,
result = DomainResult.Success(null),
)
}
}
}
} }
private fun stateFor( private fun stateFor(
@@ -19,6 +19,12 @@
<string name="login_password">密码</string> <string name="login_password">密码</string>
<string name="login_remember_username">记住用户名</string> <string name="login_remember_username">记住用户名</string>
<string name="login_auto_login">自动登录</string> <string name="login_auto_login">自动登录</string>
<string name="login_error_required">请输入用户名和密码。</string>
<string name="login_error_invalid_credentials">用户名或密码不正确。</string>
<string name="login_error_network">网络不可用,请重试。</string>
<string name="login_error_session_expired">会话已失效,请重新登录。</string>
<string name="login_error_preferences">无法加载已保存的登录设置。</string>
<string name="login_error_failed">登录失败,请重试。</string>
<string name="action_login">登录</string> <string name="action_login">登录</string>
<string name="action_logout">退出登录</string> <string name="action_logout">退出登录</string>
<string name="action_switch_provider">切换提供方</string> <string name="action_switch_provider">切换提供方</string>
@@ -19,6 +19,12 @@
<string name="login_password">Password</string> <string name="login_password">Password</string>
<string name="login_remember_username">Remember username</string> <string name="login_remember_username">Remember username</string>
<string name="login_auto_login">Sign in automatically</string> <string name="login_auto_login">Sign in automatically</string>
<string name="login_error_required">Username and password are required.</string>
<string name="login_error_invalid_credentials">Invalid username or password.</string>
<string name="login_error_network">Network unavailable. Try again.</string>
<string name="login_error_session_expired">Session expired. Sign in again.</string>
<string name="login_error_preferences">Unable to load saved login settings.</string>
<string name="login_error_failed">Sign-in failed. Try again.</string>
<string name="action_login">Sign in</string> <string name="action_login">Sign in</string>
<string name="action_logout">Sign out</string> <string name="action_logout">Sign out</string>
<string name="action_switch_provider">Switch provider</string> <string name="action_switch_provider">Switch provider</string>