feat(ui): complete ABox app experience redesign #10

Merged
KremeCN merged 40 commits from feat/ui-ux-redesign into main 2026-08-27 23:25:08 +08:00
6 changed files with 397 additions and 13 deletions
Showing only changes of commit c1c21551fb - Show all commits
@@ -52,6 +52,7 @@ fun ABoxManagerApp(
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val actionError = actionState.errorMessage?.asString()
val screenKey = sessionState.screenKey() val screenKey = sessionState.screenKey()
LaunchedEffect(screenKey) { LaunchedEffect(screenKey) {
sessionActionsViewModel.dismissError() sessionActionsViewModel.dismissError()
@@ -74,7 +75,7 @@ fun ABoxManagerApp(
is AppSessionUiState.Login -> LoginRoute( is AppSessionUiState.Login -> LoginRoute(
profile = state.profile, profile = state.profile,
bootstrapError = state.bootstrapError, bootstrapError = state.bootstrapError,
actionError = actionState.errorMessage, actionError = actionError,
actionInProgress = actionState.inProgress, actionInProgress = actionState.inProgress,
onSwitchProvider = sessionActionsViewModel::switchProvider, onSwitchProvider = sessionActionsViewModel::switchProvider,
viewModel = loginViewModel, viewModel = loginViewModel,
@@ -83,7 +84,7 @@ fun ABoxManagerApp(
username = state.username, username = state.username,
Review

错误丢失

认证态不再接收或展示 actionState.errorMessage 和 inProgress,而账户操作会先关闭弹窗再发起切换提供方、结束会话或忘记凭据。若网络或存储操作失败且会话仍保持认证态,错误只被传给登录页,当前界面不会提供任何失败反馈或重试提示。

**错误丢失** 认证态不再接收或展示 `actionState.errorMessage` 和 `inProgress`,而账户操作会先关闭弹窗再发起切换提供方、结束会话或忘记凭据。若网络或存储操作失败且会话仍保持认证态,错误只被传给登录页,当前界面不会提供任何失败反馈或重试提示。 <!-- pr-agent-dedup: e481b1f2be2d --> <!-- pr-agent-key-issue-location: 9a722f0c727c -->
profile = state.profile, profile = state.profile,
actionInProgress = actionState.inProgress, actionInProgress = actionState.inProgress,
errorMessage = actionState.errorMessage, errorMessage = actionError,
onLogout = sessionActionsViewModel::logout, onLogout = sessionActionsViewModel::logout,
) )
} }
@@ -13,13 +13,6 @@ object DomainErrorMapper {
is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request) is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request)
is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed) is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed)
is DomainError.NoData -> UiText.StringResource(R.string.error_no_data) is DomainError.NoData -> UiText.StringResource(R.string.error_no_data)
is DomainError.Unknown -> { is DomainError.Unknown -> UiText.StringResource(R.string.error_unknown)
val detail = error.detail
if (!detail.isNullOrBlank()) {
UiText.DynamicString(detail)
} else {
UiText.StringResource(R.string.error_unknown)
}
}
} }
} }
@@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.ui.common.DomainErrorMapper
import com.flagship.abox.manager.ui.common.UiText
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,9 +21,21 @@ class SessionActionsViewModel @Inject constructor(
private val _uiState = MutableStateFlow(SessionActionsUiState()) private val _uiState = MutableStateFlow(SessionActionsUiState())
val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow()
fun onAction(action: SessionUiAction) {
when (action) {
SessionUiAction.EndSession -> runAction(coordinator::endActiveSession)
SessionUiAction.ForgetSavedCredentials -> runAction(coordinator::forgetSavedCredentials)
SessionUiAction.SwitchProvider -> runAction(coordinator::switchProvider)
SessionUiAction.ValidateConnection -> runValidation()
SessionUiAction.DismissError -> dismissError()
}
}
fun dispatch(action: SessionUiAction) = onAction(action)
fun logout() = runAction(coordinator::logout) fun logout() = runAction(coordinator::logout)
fun switchProvider() = runAction(coordinator::switchProvider) fun switchProvider() = onAction(SessionUiAction.SwitchProvider)
fun dismissError() { fun dismissError() {
_uiState.value = _uiState.value.copy(errorMessage = null) _uiState.value = _uiState.value.copy(errorMessage = null)
@@ -32,7 +47,19 @@ class SessionActionsViewModel @Inject constructor(
_uiState.value = SessionActionsUiState(inProgress = true) _uiState.value = SessionActionsUiState(inProgress = true)
val result = action() val result = action()
_uiState.value = SessionActionsUiState( _uiState.value = SessionActionsUiState(
errorMessage = (result as? DomainResult.Failure)?.error?.detail, errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map),
)
}
}
private fun runValidation() {
if (_uiState.value.inProgress) return
viewModelScope.launch {
_uiState.value = SessionActionsUiState(inProgress = true)
val result = coordinator.validateActiveSession()
_uiState.value = SessionActionsUiState(
validity = (result as? DomainResult.Success)?.value,
errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map),
) )
} }
} }
@@ -40,5 +67,14 @@ class SessionActionsViewModel @Inject constructor(
data class SessionActionsUiState( data class SessionActionsUiState(
val inProgress: Boolean = false, val inProgress: Boolean = false,
val errorMessage: String? = null, val validity: SessionValidity? = null,
val errorMessage: UiText? = null,
) )
sealed interface SessionUiAction {
data object EndSession : SessionUiAction
data object ForgetSavedCredentials : SessionUiAction
data object SwitchProvider : SessionUiAction
data object ValidateConnection : SessionUiAction
data object DismissError : SessionUiAction
}
@@ -0,0 +1,197 @@
package com.flagship.abox.manager.ui.session
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeCredentialStore
import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import com.flagship.abox.manager.ui.common.UiText
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SessionActionsViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profile = ProviderProfile(
id = ProviderProfileId("abox"),
type = ProviderType.ABOX,
displayName = "ABox",
)
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
@Test
fun endSessionActionEndsSessionWithoutForgettingCredentials() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.EndSession)
advanceUntilIdle()
assertTrue(fixture.remote.session(profile.id) == null)
assertEquals(
credentials,
fixture.credentials.loadCredentials(profile.id).successValue(),
)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun forgetCredentialsActionKeepsSessionAndDisablesAutoLogin() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.ForgetSavedCredentials)
advanceUntilIdle()
assertEquals(profile.id, fixture.remote.session(profile.id)?.profileId)
assertNull(fixture.credentials.loadCredentials(profile.id).successValue())
assertTrue(!fixture.profiles.loginPreferences(profile.id).autoLoginEnabled)
}
@Test
fun switchProviderActionClearsProfileOnlyAfterLogout() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.login(profile.id, credentials)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.SwitchProvider)
advanceUntilIdle()
assertEquals(profile.id, fixture.remote.logoutProfileId)
assertNull(fixture.profiles.activeProfile())
}
@Test
fun validateConnectionActionDelegatesWithoutRelogin() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.setValidationResult(
profile.id,
com.flagship.abox.manager.domain.DomainResult.Success(SessionValidity.INVALID),
)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.ValidateConnection)
advanceUntilIdle()
assertEquals(SessionValidity.INVALID, fixture.remote.lastValidation)
assertEquals(0, fixture.remote.loginCalls)
}
@Test
fun mappedActionErrorNeverExposesDomainDetail() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.logoutError = DomainError.Unknown(detail = "keystore secret detail")
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.dispatch(SessionUiAction.EndSession)
advanceUntilIdle()
assertEquals(
UiText.StringResource(R.string.error_unknown),
viewModel.uiState.value.errorMessage,
)
}
@Test
fun dismissErrorActionClearsMappedError() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.logoutError = DomainError.Network(detail = "private network detail")
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.EndSession)
advanceUntilIdle()
viewModel.onAction(SessionUiAction.DismissError)
assertNull(viewModel.uiState.value.errorMessage)
}
private class Fixture {
val profiles = FakeProviderProfileStore()
val credentials = FakeCredentialStore()
val remote = RecordingSessionGateway(profiles)
val local = FakeLocalSessionGateway()
val coordinator = SessionCoordinator(profiles, credentials, remote, local)
suspend fun activateAuthenticatedProfile() {
profiles.saveProfile(
ProviderProfile(
id = ProviderProfileId("abox"),
type = ProviderType.ABOX,
displayName = "ABox",
),
)
profiles.setActiveProfile(ProviderProfileId("abox"))
}
}
private class RecordingSessionGateway(
private val profiles: FakeProviderProfileStore,
private val delegate: FakeSessionGateway = FakeSessionGateway(),
) : com.flagship.abox.manager.domain.SessionGateway by delegate {
var loginCalls = 0
private set
var logoutProfileId: ProviderProfileId? = null
private set
var lastValidation: SessionValidity? = null
private set
var logoutError: DomainError? = null
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
) = delegate.login(profileId, credentials).also { loginCalls++ }
override suspend fun validateSession(profileId: ProviderProfileId) = delegate
.validateSession(profileId)
.also { result ->
lastValidation = (result as? com.flagship.abox.manager.domain.DomainResult.Success)?.value
}
override suspend fun logout(profileId: ProviderProfileId) = logoutError?.let {
logoutProfileId = profiles.activeProfile()?.id
com.flagship.abox.manager.domain.DomainResult.Failure(it)
} ?: delegate.logout(profileId).also {
logoutProfileId = profiles.activeProfile()?.id
}
suspend fun session(profileId: ProviderProfileId) = delegate.observeSession(profileId)
.let { flow -> flow.first() }
fun setValidationResult(
profileId: ProviderProfileId,
result: com.flagship.abox.manager.domain.DomainResult<SessionValidity>,
) {
delegate.setValidationResult(profileId, result)
}
}
}
private fun <T> com.flagship.abox.manager.domain.DomainResult<T>.successValue(): T? =
(this as? com.flagship.abox.manager.domain.DomainResult.Success)?.value
@@ -147,6 +147,35 @@ class SessionCoordinator(
} }
} }
suspend fun endActiveSession(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val logoutResult = profile.gateway().logout(profile.id)) {
is DomainResult.Failure -> return@storeOperation logoutResult
is DomainResult.Success -> Unit
}
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
profileStore.setActiveProfile(null)
} else {
DomainResult.Success(Unit)
}
}
suspend fun forgetSavedCredentials(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
is DomainResult.Failure -> return@storeOperation credentialResult
is DomainResult.Success -> Unit
}
val preferences = profileStore.loginPreferences(profile.id)
profileStore.saveLoginPreferences(preferences.copy(autoLoginEnabled = false))
}
suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation {
val profile = profileStore.activeProfile()
?: return@storeOperation DomainResult.Success(SessionValidity.INVALID)
profile.gateway().validateSession(profile.id)
}
suspend fun logout(): DomainResult<Unit> = storeOperation { suspend fun logout(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit) val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val credentialResult = credentialStore.clearCredentials(profile.id)) { when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
@@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionGateway import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -133,6 +134,101 @@ class SessionCoordinatorTest {
) )
} }
@Test
fun endActiveSessionKeepsAboxCredentials() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(remoteProfile)
fixture.coordinator.login(
remoteProfile.id,
credentials,
rememberUsername = true,
autoLogin = true,
)
val result = fixture.coordinator.endActiveSession()
assertTrue(result is DomainResult.Success)
assertEquals(remoteProfile, fixture.profiles.activeProfile())
assertNull(fixture.remote.observeSession(remoteProfile.id).first())
assertEquals(
credentials,
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
assertTrue(fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled)
}
@Test
fun forgetSavedCredentialsDisablesAutoLoginAndKeepsSessionStateExplicit() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(remoteProfile)
fixture.coordinator.login(
remoteProfile.id,
credentials,
rememberUsername = true,
autoLogin = true,
)
val result = fixture.coordinator.forgetSavedCredentials()
assertTrue(result is DomainResult.Success)
assertEquals(remoteProfile, fixture.profiles.activeProfile())
assertEquals(
remoteProfile.id,
fixture.remote.observeSession(remoteProfile.id).first()?.profileId,
)
assertNull(
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
val preferences = fixture.profiles.loginPreferences(remoteProfile.id)
assertEquals(credentials.username, preferences.rememberedUsername)
assertTrue(!preferences.autoLoginEnabled)
}
@Test
fun switchProviderClearsActiveProfileAfterGatewayLogout() = runTest {
val profiles = FakeProviderProfileStore()
val remote = RecordingSessionGateway(profiles)
val coordinator = SessionCoordinator(
profileStore = profiles,
credentialStore = FakeCredentialStore(),
remoteGateway = remote,
localGateway = FakeLocalSessionGateway(),
)
coordinator.selectProvider(remoteProfile)
remote.login(remoteProfile.id, credentials)
val result = coordinator.switchProvider()
assertTrue(result is DomainResult.Success)
assertEquals(remoteProfile.id, remote.activeProfileAtLogout)
assertNull(profiles.activeProfile())
}
@Test
fun validateActiveSessionDoesNotRelogin() = runTest {
val remote = CountingSessionGateway()
val credentialsStore = CountingCredentialStore()
val profiles = FakeProviderProfileStore()
val coordinator = SessionCoordinator(
profileStore = profiles,
credentialStore = credentialsStore,
remoteGateway = remote,
localGateway = FakeLocalSessionGateway(),
)
profiles.saveProfile(remoteProfile)
profiles.setActiveProfile(remoteProfile.id)
remote.setValidationResult(
remoteProfile.id,
DomainResult.Success(SessionValidity.INVALID),
)
val result = coordinator.validateActiveSession()
assertEquals(DomainResult.Success(SessionValidity.INVALID), result)
assertEquals(0, remote.loginCalls)
assertEquals(0, credentialsStore.loadCalls)
}
@Test @Test
fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest { fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest {
val fixture = Fixture() val fixture = Fixture()
@@ -189,6 +285,18 @@ class SessionCoordinatorTest {
assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled)
} }
@Test
fun endActiveSessionClearsLocalActiveProfile() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(localProfile)
val result = fixture.coordinator.endActiveSession()
assertTrue(result is DomainResult.Success)
assertNull(fixture.profiles.activeProfile())
assertNull(fixture.local.observeSession(localProfile.id).first())
}
private class Fixture { private class Fixture {
val profiles = FakeProviderProfileStore() val profiles = FakeProviderProfileStore()
val credentials = FakeCredentialStore() val credentials = FakeCredentialStore()
@@ -197,6 +305,19 @@ class SessionCoordinatorTest {
val coordinator = SessionCoordinator(profiles, credentials, remote, local) val coordinator = SessionCoordinator(profiles, credentials, remote, local)
} }
private class RecordingSessionGateway(
private val profileStore: FakeProviderProfileStore,
private val delegate: FakeSessionGateway = FakeSessionGateway(),
) : SessionGateway by delegate {
var activeProfileAtLogout: ProviderProfileId? = null
private set
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
activeProfileAtLogout = profileStore.activeProfile()?.id
return delegate.logout(profileId)
}
}
private class CountingSessionGateway( private class CountingSessionGateway(
private val delegate: FakeSessionGateway = FakeSessionGateway(), private val delegate: FakeSessionGateway = FakeSessionGateway(),
) : SessionGateway by delegate { ) : SessionGateway by delegate {
@@ -214,6 +335,13 @@ class SessionCoordinatorTest {
fun failLogin(profileId: ProviderProfileId, error: DomainError) { fun failLogin(profileId: ProviderProfileId, error: DomainError) {
delegate.failLogin(profileId, error) delegate.failLogin(profileId, error)
} }
fun setValidationResult(
profileId: ProviderProfileId,
result: DomainResult<SessionValidity>,
) {
delegate.setValidationResult(profileId, result)
}
} }
private class CountingCredentialStore( private class CountingCredentialStore(