feat: add provider login flow

This commit is contained in:
2026-08-23 21:58:04 +08:00
parent 6f7d3248b0
commit cc8eeebdc4
43 changed files with 2277 additions and 91 deletions
@@ -0,0 +1,76 @@
package com.flagship.abox.manager.ui.login
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val profile = ProviderProfile(
ProviderProfileId("abox"),
ProviderType.ABOX,
"ABox",
)
@Test
fun backControlsSwitchProviderBeforeLogin() {
var switches = 0
composeRule.setContent {
ABoxTheme {
LoginScreen(
profile = profile,
state = LoginUiState(),
onUsernameChange = {},
onPasswordChange = {},
onRememberUsernameChange = {},
onAutoLoginChange = {},
onSubmit = {},
onSwitchProvider = { switches++ },
)
}
}
composeRule.onNodeWithContentDescription("back-to-providers")
.assertIsEnabled()
.performClick()
composeRule.onNodeWithText("Back to providers").performClick()
assertEquals(2, switches)
}
@Test
fun backControlsAreDisabledWhileSubmitting() {
composeRule.setContent {
ABoxTheme {
LoginScreen(
profile = profile,
state = LoginUiState(isSubmitting = true),
onUsernameChange = {},
onPasswordChange = {},
onRememberUsernameChange = {},
onAutoLoginChange = {},
onSubmit = {},
onSwitchProvider = {},
)
}
}
composeRule.onNodeWithContentDescription("back-to-providers").assertIsNotEnabled()
composeRule.onNodeWithText("Back to providers").assertIsNotEnabled()
}
}
@@ -0,0 +1,52 @@
package com.flagship.abox.manager.ui.provider
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ProviderSelectionScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun enabledOptionCanBeSelectedAndDisabledOptionCannot() {
val local = ProviderOption(
ProviderProfile(ProviderProfileId("local"), ProviderType.LOCAL_EXPERIENCE, "Local"),
Availability.Enabled,
)
val compatible = ProviderOption(
ProviderProfile(
ProviderProfileId("compatible"),
ProviderType.COMPATIBLE_BACKEND,
"Compatible",
),
Availability.Disabled(UnavailableReason.PROTOCOL_PENDING),
)
var selected: ProviderOption? = null
composeRule.setContent {
ABoxTheme {
ProviderSelectionScreen(
state = ProviderSelectionUiState(listOf(local, compatible)),
onSelect = { selected = it },
)
}
}
composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick()
assertEquals(local, selected)
composeRule.onNodeWithText("Compatible backend").assertIsNotEnabled()
}
}
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import com.flagship.abox.manager.ui.provider.Availability
import com.flagship.abox.manager.ui.provider.ProviderAvailability
import com.flagship.abox.manager.ui.provider.UnavailableReason
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DebugSessionModule {
@Provides
@Singleton
fun provideRemoteSessionGateway(fake: FakeSessionGateway): SessionGateway = fake
@Provides
@Singleton
fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability(
abox = Availability.Simulation,
compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING),
)
}
@@ -2,7 +2,6 @@ package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
@@ -21,7 +20,7 @@ import javax.inject.Singleton
object FoundationModule {
@Provides
@Singleton
fun provideSessionGateway(): SessionGateway = FakeSessionGateway()
fun provideFakeSessionGateway(): FakeSessionGateway = FakeSessionGateway()
@Provides
@Singleton
@@ -0,0 +1,67 @@
package com.flagship.abox.manager.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.flagship.abox.manager.domain.CredentialStore
import com.flagship.abox.manager.domain.LocalSessionGateway
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.session.credential.AndroidKeystoreCredentialCipher
import com.flagship.abox.manager.session.credential.CredentialCipher
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
import com.flagship.abox.manager.session.gateway.LocalSessionGatewayImpl
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
import com.flagship.abox.manager.session.profile.sessionDataStore
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SessionModule {
@Provides
@Singleton
fun provideSessionDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.sessionDataStore
@Provides
@Singleton
fun provideProviderProfileStore(
dataStore: DataStore<Preferences>,
): ProviderProfileStore = ProviderProfileStoreImpl(dataStore)
@Provides
@Singleton
fun provideCredentialCipher(): CredentialCipher = AndroidKeystoreCredentialCipher()
@Provides
@Singleton
fun provideCredentialStore(
dataStore: DataStore<Preferences>,
cipher: CredentialCipher,
): CredentialStore = KeystoreCredentialStore(dataStore, cipher)
@Provides
@Singleton
fun provideLocalSessionGateway(): LocalSessionGateway = LocalSessionGatewayImpl()
@Provides
@Singleton
fun provideSessionCoordinator(
profileStore: ProviderProfileStore,
credentialStore: CredentialStore,
remoteGateway: SessionGateway,
localGateway: LocalSessionGateway,
): SessionCoordinator = SessionCoordinator(
profileStore,
credentialStore,
remoteGateway,
localGateway,
)
}
@@ -1,94 +1,60 @@
package com.flagship.abox.manager.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
private const val FOUNDATION_ROUTE = "foundation"
private const val PLACEHOLDER_ROUTE = "placeholder"
import com.flagship.abox.manager.ui.authenticated.AuthenticatedPlaceholderScreen
import com.flagship.abox.manager.ui.login.LoginRoute
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
import com.flagship.abox.manager.ui.session.AppSessionUiState
import com.flagship.abox.manager.ui.session.AppSessionViewModel
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
@Composable
fun ABoxManagerApp() {
fun ABoxManagerApp(
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
) {
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
ABoxTheme {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = FOUNDATION_ROUTE,
when (
val state = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
) {
composable(FOUNDATION_ROUTE) {
FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) })
}
composable(PLACEHOLDER_ROUTE) {
MessageScreen(
title = stringResource(R.string.placeholder_title),
description = stringResource(R.string.placeholder_description),
)
AppSessionUiState.Loading -> Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.Center,
) {
ABoxLoadingIndicator()
}
AppSessionUiState.ProviderSelection -> ProviderSelectionRoute()
is AppSessionUiState.Login -> LoginRoute(
profile = state.profile,
bootstrapError = state.bootstrapError,
actionError = actionState.errorMessage,
actionInProgress = actionState.inProgress,
onSwitchProvider = sessionActionsViewModel::switchProvider,
)
is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen(
profile = state.profile,
actionInProgress = actionState.inProgress,
errorMessage = actionState.errorMessage,
onLogout = sessionActionsViewModel::logout,
onSwitchProvider = sessionActionsViewModel::switchProvider,
)
}
}
}
}
@Composable
private fun FoundationScreen(onContinue: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
MessageScreen(
title = stringResource(R.string.foundation_title),
description = stringResource(R.string.foundation_description),
)
ABoxButton(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
) {
Text(stringResource(R.string.continue_action))
}
}
}
@Composable
private fun MessageScreen(
title: String,
description: String,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = title,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = description,
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,76 @@
package com.flagship.abox.manager.ui.authenticated
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.domain.ProviderProfile
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthenticatedPlaceholderScreen(
profile: ProviderProfile,
actionInProgress: Boolean,
errorMessage: String?,
onLogout: () -> Unit,
onSwitchProvider: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.authenticated_title, profile.displayName)) },
)
},
) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 520.dp)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.authenticated_description),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) }
ABoxButton(
onClick = onLogout,
enabled = !actionInProgress,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.action_logout))
}
ABoxButton(
onClick = onSwitchProvider,
enabled = !actionInProgress,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.action_switch_provider))
}
}
}
}
}
@@ -0,0 +1,212 @@
package com.flagship.abox.manager.ui.login
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderType
@Composable
fun LoginRoute(
profile: ProviderProfile,
bootstrapError: String? = null,
actionError: String? = null,
actionInProgress: Boolean = false,
onSwitchProvider: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
) {
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
val state = viewModel.uiState.collectAsStateWithLifecycle().value
LoginScreen(
profile = profile,
state = state.copy(
errorMessage = state.errorMessage ?: actionError ?: bootstrapError,
),
onUsernameChange = viewModel::setUsername,
onPasswordChange = viewModel::setPassword,
onRememberUsernameChange = viewModel::setRememberUsername,
onAutoLoginChange = viewModel::setAutoLogin,
onSubmit = viewModel::submit,
onSwitchProvider = onSwitchProvider,
switchingProvider = actionInProgress,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
profile: ProviderProfile,
state: LoginUiState,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onRememberUsernameChange: (Boolean) -> Unit,
onAutoLoginChange: (Boolean) -> Unit,
onSubmit: () -> Unit,
onSwitchProvider: () -> Unit,
switchingProvider: Boolean = false,
) {
val navigationEnabled = !state.isSubmitting && !switchingProvider
BackHandler(enabled = navigationEnabled, onBack = onSwitchProvider)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.login_title, providerName(profile.type))) },
navigationIcon = {
IconButton(
onClick = onSwitchProvider,
enabled = navigationEnabled,
modifier = Modifier.semantics {
contentDescription = "back-to-providers"
},
) {
Text(
text = "‹",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 4.dp),
)
}
},
)
},
) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 520.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
stringResource(R.string.login_provider_context, providerName(profile.type)),
style = MaterialTheme.typography.titleMedium,
)
Text(
providerDescription(profile.type),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChange,
label = { Text(stringResource(R.string.login_username)) },
enabled = !state.isSubmitting,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.login_password)) },
visualTransformation = PasswordVisualTransformation(),
enabled = !state.isSubmitting,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
ToggleRow(
label = stringResource(R.string.login_remember_username),
checked = state.rememberUsername,
enabled = !state.isSubmitting,
onCheckedChange = onRememberUsernameChange,
)
ToggleRow(
label = stringResource(R.string.login_auto_login),
checked = state.autoLogin,
enabled = !state.isSubmitting,
onCheckedChange = onAutoLoginChange,
)
state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) }
ABoxButton(
onClick = onSubmit,
enabled = !state.isSubmitting && !switchingProvider,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isSubmitting || switchingProvider) ABoxLoadingIndicator()
else Text(stringResource(R.string.action_login))
}
ABoxButton(
onClick = onSwitchProvider,
enabled = navigationEnabled,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.login_back_to_providers))
}
}
}
}
}
@Composable
private fun ToggleRow(
label: String,
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
Text(label)
}
}
@Composable
private fun providerName(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_name
ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_name
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_name
},
)
@Composable
private fun providerDescription(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_description
ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_description
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description
},
)
@@ -0,0 +1,98 @@
package com.flagship.abox.manager.ui.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
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.ProviderProfileStore
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.Username
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class LoginViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
private val profileStore: ProviderProfileStore,
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private var profile: ProviderProfile? = null
private var loadingJob: Job? = null
fun loadProfile(value: ProviderProfile) {
if (profile?.id == value.id && loadingJob != null) return
profile = value
loadingJob = viewModelScope.launch {
val preferences = profileStore.loginPreferences(value.id)
_uiState.value = _uiState.value.copy(
username = preferences.rememberedUsername?.value.orEmpty(),
rememberUsername = preferences.rememberUsername,
autoLogin = preferences.autoLoginEnabled,
)
}
}
fun setUsername(value: String) {
_uiState.value = _uiState.value.copy(username = value, errorMessage = null)
}
fun setPassword(value: String) {
_uiState.value = _uiState.value.copy(password = value, errorMessage = null)
}
fun setRememberUsername(value: Boolean) {
_uiState.value = _uiState.value.copy(rememberUsername = value)
}
fun setAutoLogin(value: Boolean) {
_uiState.value = _uiState.value.copy(autoLogin = value)
}
fun submit() {
val activeProfile = profile ?: return
val state = _uiState.value
if (state.username.isBlank() || state.password.isEmpty() || state.isSubmitting) {
_uiState.value = state.copy(errorMessage = "Username and password are required")
return
}
viewModelScope.launch {
_uiState.value = state.copy(isSubmitting = true, errorMessage = null)
val result = coordinator.login(
activeProfile.id,
LoginCredentials(Username(state.username.trim()), Password(state.password)),
rememberUsername = state.rememberUsername,
autoLogin = state.autoLogin,
)
_uiState.value = _uiState.value.copy(
password = "",
isSubmitting = false,
errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(),
)
}
}
private fun DomainError.toUiMessage(): String = when (this) {
is DomainError.Authentication -> "Invalid username or password"
is DomainError.Network -> "Network unavailable"
is DomainError.TokenInvalid -> "Session expired"
else -> detail ?: "Login failed"
}
}
data class LoginUiState(
val username: String = "",
val password: String = "",
val rememberUsername: Boolean = false,
val autoLogin: Boolean = false,
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
)
@@ -0,0 +1,10 @@
package com.flagship.abox.manager.ui.navigation
object Routes {
const val STARTUP = "startup"
const val PROVIDER_SELECTION = "provider_selection"
const val LOGIN = "login"
/** Login-complete route contract; the device navigation owner replaces its placeholder. */
const val AUTHENTICATED = "authenticated"
}
@@ -0,0 +1,28 @@
package com.flagship.abox.manager.ui.provider
import com.flagship.abox.manager.domain.ProviderType
data class ProviderAvailability(
val abox: Availability,
val compatible: Availability,
val local: Availability = Availability.Enabled,
) {
fun forType(type: ProviderType): Availability = when (type) {
ProviderType.ABOX -> abox
ProviderType.COMPATIBLE_BACKEND -> compatible
ProviderType.LOCAL_EXPERIENCE -> local
}
}
sealed interface Availability {
data object Enabled : Availability
data object Simulation : Availability
data class Disabled(val reason: UnavailableReason) : Availability
}
enum class UnavailableReason {
PROTOCOL_PENDING,
TRANSPORT_SECURITY_PENDING,
}
@@ -0,0 +1,225 @@
package com.flagship.abox.manager.ui.provider
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.ProviderType
@Composable
fun ProviderSelectionRoute(
viewModel: ProviderSelectionViewModel = hiltViewModel(),
) {
val state = viewModel.uiState.collectAsStateWithLifecycle().value
ProviderSelectionScreen(state = state, onSelect = viewModel::select)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProviderSelectionScreen(
state: ProviderSelectionUiState,
onSelect: (ProviderOption) -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.provider_selection_title)) })
},
) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 640.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.provider_selection_description),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 6.dp),
)
state.providers.forEach { option ->
ProviderOptionCard(
option = option,
selecting = state.isSelecting,
onSelect = onSelect,
)
}
state.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
if (state.isSelecting) {
ABoxLoadingIndicator(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp),
)
}
Spacer(Modifier.padding(bottom = 12.dp))
}
}
}
}
@Composable
private fun ProviderOptionCard(
option: ProviderOption,
selecting: Boolean,
onSelect: (ProviderOption) -> Unit,
) {
val enabled = option.availability !is Availability.Disabled && !selecting
val displayName = providerName(option.profile.type)
val description = providerDescription(option.profile.type)
val status = availabilityLabel(option.availability)
val statusColor = availabilityColor(option.availability)
OutlinedCard(
onClick = { onSelect(option) },
enabled = enabled,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.outlinedCardColors(
containerColor = when (option.availability) {
Availability.Enabled -> MaterialTheme.colorScheme.surface
Availability.Simulation -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
is Availability.Disabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)
},
),
modifier = Modifier
.fillMaxWidth()
.alpha(if (enabled || selecting) 1f else 0.72f)
.semantics {
role = Role.Button
contentDescription = "$displayName, $status"
},
) {
Row(
modifier = Modifier.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(statusColor.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Text(
text = providerMark(option.profile.type),
style = MaterialTheme.typography.titleMedium,
color = statusColor,
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(displayName, style = MaterialTheme.typography.titleMedium)
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColor),
)
Text(
status,
style = MaterialTheme.typography.labelLarge,
color = statusColor,
)
}
}
Text("›", style = MaterialTheme.typography.headlineSmall, color = statusColor)
}
}
}
@Composable
private fun providerName(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_name
ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_name
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_name
},
)
@Composable
private fun providerDescription(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_description
ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_description
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description
},
)
private fun providerMark(type: ProviderType): String = when (type) {
ProviderType.ABOX -> "A"
ProviderType.COMPATIBLE_BACKEND -> "C"
ProviderType.LOCAL_EXPERIENCE -> "L"
}
@Composable
private fun availabilityLabel(availability: Availability): String = stringResource(
when (availability) {
Availability.Enabled -> R.string.provider_status_available
Availability.Simulation -> R.string.provider_status_simulation
is Availability.Disabled -> when (availability.reason) {
UnavailableReason.PROTOCOL_PENDING -> R.string.provider_status_protocol_pending
UnavailableReason.TRANSPORT_SECURITY_PENDING -> R.string.provider_status_transport_pending
}
},
)
@Composable
private fun availabilityColor(availability: Availability) = when (availability) {
Availability.Enabled -> MaterialTheme.colorScheme.primary
Availability.Simulation -> MaterialTheme.colorScheme.tertiary
is Availability.Disabled -> MaterialTheme.colorScheme.error
}
@@ -0,0 +1,69 @@
package com.flagship.abox.manager.ui.provider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class ProviderSelectionViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
availability: ProviderAvailability,
) : ViewModel() {
private val _uiState = MutableStateFlow(
ProviderSelectionUiState(
providers = PROFILES.map { profile ->
ProviderOption(profile, availability.forType(profile.type))
},
),
)
val uiState: StateFlow<ProviderSelectionUiState> = _uiState.asStateFlow()
fun select(option: ProviderOption) {
if (option.availability is Availability.Disabled || _uiState.value.isSelecting) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSelecting = true, errorMessage = null)
val result = coordinator.selectProvider(option.profile)
_uiState.value = _uiState.value.copy(
isSelecting = false,
errorMessage = (result as? com.flagship.abox.manager.domain.DomainResult.Failure)
?.error?.detail,
)
}
}
companion object {
private val PROFILES = listOf(
ProviderProfile(ProviderProfileId("abox-default"), ProviderType.ABOX, "ABox"),
ProviderProfile(
ProviderProfileId("compatible-default"),
ProviderType.COMPATIBLE_BACKEND,
"Compatible backend",
),
ProviderProfile(
ProviderProfileId("local-experience"),
ProviderType.LOCAL_EXPERIENCE,
"Local experience",
),
)
}
}
data class ProviderSelectionUiState(
val providers: List<ProviderOption>,
val isSelecting: Boolean = false,
val errorMessage: String? = null,
)
data class ProviderOption(
val profile: ProviderProfile,
val availability: Availability,
)
@@ -0,0 +1,64 @@
package com.flagship.abox.manager.ui.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionCoordinator
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class AppSessionViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
profileStore: ProviderProfileStore,
) : ViewModel() {
private val bootstrapResult = MutableStateFlow<DomainResult<Session?>?>(null)
val uiState: StateFlow<AppSessionUiState> = combine(
bootstrapResult,
profileStore.observeActiveProfile(),
coordinator.observeActiveSession(),
) { result, profile, session ->
if (result == null) AppSessionUiState.Loading
else stateFor(profile, session, (result as? DomainResult.Failure)?.error?.detail)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AppSessionUiState.Loading,
)
init {
viewModelScope.launch {
bootstrapResult.value = coordinator.bootstrap()
}
}
private fun stateFor(
profile: ProviderProfile?,
session: Session?,
bootstrapError: String?,
): AppSessionUiState = when {
profile == null -> AppSessionUiState.ProviderSelection
session != null -> AppSessionUiState.Authenticated(profile)
else -> AppSessionUiState.Login(profile, bootstrapError)
}
}
sealed interface AppSessionUiState {
data object Loading : AppSessionUiState
data object ProviderSelection : AppSessionUiState
data class Login(
val profile: ProviderProfile,
val bootstrapError: String? = null,
) : AppSessionUiState
data class Authenticated(val profile: ProviderProfile) : AppSessionUiState
}
@@ -0,0 +1,44 @@
package com.flagship.abox.manager.ui.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.SessionCoordinator
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class SessionActionsViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
) : ViewModel() {
private val _uiState = MutableStateFlow(SessionActionsUiState())
val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow()
fun logout() = runAction(coordinator::logout)
fun switchProvider() = runAction(coordinator::switchProvider)
fun dismissError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
private fun runAction(action: suspend () -> DomainResult<Unit>) {
if (_uiState.value.inProgress) return
viewModelScope.launch {
_uiState.value = SessionActionsUiState(inProgress = true)
val result = action()
_uiState.value = SessionActionsUiState(
errorMessage = (result as? DomainResult.Failure)?.error?.detail,
)
}
}
}
data class SessionActionsUiState(
val inProgress: Boolean = false,
val errorMessage: String? = null,
)
@@ -1,8 +1,27 @@
<resources>
<string name="app_name">ABox Manager</string>
<string name="foundation_title">工程基础已就绪</string>
<string name="foundation_description">公共领域接口和本地开发提供方已经可用。</string>
<string name="continue_action">继续</string>
<string name="placeholder_title">功能模块可以开始开发</string>
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
<string name="provider_selection_title">选择提供方</string>
<string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
<string name="provider_abox_name">ABox</string>
<string name="provider_abox_description">通过兼容 ABSDK 的 ABox 服务连接设备。</string>
<string name="provider_compatible_name">自有兼容后端</string>
<string name="provider_compatible_description">在协议准备完成后连接自行部署的 ABSDK 兼容服务。</string>
<string name="provider_local_name">本地体验</string>
<string name="provider_local_description">无需网络,通过本地模拟设备体验完整应用流程。</string>
<string name="provider_status_available">可用</string>
<string name="provider_status_simulation">开发模拟</string>
<string name="provider_status_protocol_pending">协议待确认</string>
<string name="provider_status_transport_pending">等待传输安全方案批准</string>
<string name="login_title">登录 %1$s</string>
<string name="login_provider_context">当前提供方:%1$s</string>
<string name="login_back_to_providers">返回提供方选择</string>
<string name="login_username">用户名</string>
<string name="login_password">密码</string>
<string name="login_remember_username">记住用户名</string>
<string name="login_auto_login">自动登录</string>
<string name="action_login">登录</string>
<string name="action_logout">退出登录</string>
<string name="action_switch_provider">切换提供方</string>
<string name="authenticated_title">已连接到 %1$s</string>
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
</resources>
+24 -5
View File
@@ -1,8 +1,27 @@
<resources>
<string name="app_name">ABox Manager</string>
<string name="foundation_title">Project foundation ready</string>
<string name="foundation_description">The shared domain contracts and local development providers are available.</string>
<string name="continue_action">Continue</string>
<string name="placeholder_title">Feature development can start</string>
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string>
<string name="provider_selection_title">Choose a provider</string>
<string name="provider_selection_description">Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers.</string>
<string name="provider_abox_name">ABox</string>
<string name="provider_abox_description">Connect through the ABSDK-compatible ABox service.</string>
<string name="provider_compatible_name">Compatible backend</string>
<string name="provider_compatible_description">Use a self-hosted ABSDK-compatible service when its protocol is ready.</string>
<string name="provider_local_name">Local experience</string>
<string name="provider_local_description">Explore the complete app flow with local simulated devices and no network.</string>
<string name="provider_status_available">Available</string>
<string name="provider_status_simulation">Development simulation</string>
<string name="provider_status_protocol_pending">Protocol pending</string>
<string name="provider_status_transport_pending">Transport security approval required</string>
<string name="login_title">Sign in to %1$s</string>
<string name="login_provider_context">Selected provider: %1$s</string>
<string name="login_back_to_providers">Back to providers</string>
<string name="login_username">Username</string>
<string name="login_password">Password</string>
<string name="login_remember_username">Remember username</string>
<string name="login_auto_login">Sign in automatically</string>
<string name="action_login">Sign in</string>
<string name="action_logout">Sign out</string>
<string name="action_switch_provider">Switch provider</string>
<string name="authenticated_title">Connected to %1$s</string>
<string name="authenticated_description">The authenticated route is ready. Device navigation will be added by its module owner.</string>
</resources>
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.session.gateway.UnavailableRemoteSessionGateway
import com.flagship.abox.manager.ui.provider.Availability
import com.flagship.abox.manager.ui.provider.ProviderAvailability
import com.flagship.abox.manager.ui.provider.UnavailableReason
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ReleaseSessionModule {
@Provides
@Singleton
fun provideRemoteSessionGateway(): SessionGateway = UnavailableRemoteSessionGateway()
@Provides
@Singleton
fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability(
abox = Availability.Disabled(UnavailableReason.TRANSPORT_SECURITY_PENDING),
compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING),
)
}