feat: add provider login flow
This commit is contained in:
+76
@@ -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()
|
||||
}
|
||||
}
|
||||
+52
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -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"
|
||||
}
|
||||
+28
@@ -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,
|
||||
}
|
||||
+225
@@ -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
|
||||
}
|
||||
+69
@@ -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,
|
||||
)
|
||||
+64
@@ -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
|
||||
}
|
||||
+44
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user