diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt index b10ea08..10924e4 100644 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt @@ -2,7 +2,6 @@ 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 @@ -22,27 +21,26 @@ class ProviderSelectionScreenTest { val composeRule = createAndroidComposeRule() @Test - fun enabledOptionCanBeSelectedAndDisabledOptionCannot() { - val local = ProviderOption( - ProviderProfile(ProviderProfileId("local"), ProviderType.LOCAL_EXPERIENCE, "Local"), - Availability.Enabled, + fun aboxAndLocalExperienceAreBothSelectableAndNoCompatibleOptionIsShown() { + val abox = ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox") + val local = ProviderProfile( + ProviderProfileId("local"), + ProviderType.LOCAL_EXPERIENCE, + "Local", ) - val abox = ProviderOption( - ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox"), - Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), - ) - var selected: ProviderOption? = null + var selected: ProviderProfile? = null composeRule.setContent { ABoxTheme { ProviderSelectionScreen( - state = ProviderSelectionUiState(listOf(local, abox)), + state = ProviderSelectionUiState(listOf(abox, local)), onSelect = { selected = it }, ) } } + composeRule.onNodeWithText("ABox").assertIsEnabled() composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick() assertEquals(local, selected) - composeRule.onNodeWithText("ABox").assertIsNotEnabled() + composeRule.onNodeWithText("Compatible backend").assertDoesNotExist() } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt index a2425ba..a2ca3ec 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt @@ -16,9 +16,6 @@ import com.flagship.abox.manager.session.credential.KeystoreCredentialStore import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl import com.flagship.abox.manager.session.profile.sessionDataStore import com.flagship.abox.manager.simulation.SimulationSessionGateway -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 @@ -67,13 +64,6 @@ object SessionModule { @Singleton fun provideLocalSessionGateway(): LocalSessionGateway = SimulationSessionGateway() - @Provides - @Singleton - fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability( - abox = Availability.Enabled, - compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING), - ) - @Provides @Singleton fun provideSessionCoordinator( diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt deleted file mode 100644 index c4839ca..0000000 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt +++ /dev/null @@ -1,27 +0,0 @@ -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.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, -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt index 1829e22..6c486d5 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt @@ -24,7 +24,6 @@ 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 @@ -36,6 +35,7 @@ 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.ProviderProfile import com.flagship.abox.manager.domain.ProviderType @Composable @@ -50,7 +50,7 @@ fun ProviderSelectionRoute( @Composable fun ProviderSelectionScreen( state: ProviderSelectionUiState, - onSelect: (ProviderOption) -> Unit, + onSelect: (ProviderProfile) -> Unit, ) { Scaffold( topBar = { @@ -77,9 +77,9 @@ fun ProviderSelectionScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp), ) - state.providers.forEach { option -> + state.providers.forEach { profile -> ProviderOptionCard( - option = option, + profile = profile, selecting = state.isSelecting, onSelect = onSelect, ) @@ -102,33 +102,25 @@ fun ProviderSelectionScreen( @Composable private fun ProviderOptionCard( - option: ProviderOption, + profile: ProviderProfile, selecting: Boolean, - onSelect: (ProviderOption) -> Unit, + onSelect: (ProviderProfile) -> 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) + val displayName = providerName(profile.type) + val description = providerDescription(profile.type) OutlinedCard( - onClick = { onSelect(option) }, - enabled = enabled, + onClick = { onSelect(profile) }, + enabled = !selecting, 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) - }, + containerColor = MaterialTheme.colorScheme.surface, ), modifier = Modifier .fillMaxWidth() - .alpha(if (enabled || selecting) 1f else 0.72f) .semantics { role = Role.Button - contentDescription = "$displayName, $status" + contentDescription = displayName }, ) { Row( @@ -140,13 +132,13 @@ private fun ProviderOptionCard( modifier = Modifier .size(48.dp) .clip(CircleShape) - .background(statusColor.copy(alpha = 0.16f)), + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)), contentAlignment = Alignment.Center, ) { Text( - text = providerMark(option.profile.type), + text = providerMark(profile.type), style = MaterialTheme.typography.titleMedium, - color = statusColor, + color = MaterialTheme.colorScheme.primary, ) } Column( @@ -159,24 +151,12 @@ private fun ProviderOptionCard( 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) + Text( + "›", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + ) } } } @@ -201,22 +181,3 @@ private fun providerMark(type: ProviderType): String = when (type) { ProviderType.ABOX -> "A" 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 -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt index 8eb54f8..04e1ddd 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt @@ -2,6 +2,7 @@ package com.flagship.abox.manager.ui.provider 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.ProviderProfileId import com.flagship.abox.manager.domain.ProviderType @@ -16,26 +17,18 @@ 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)) - }, - ), - ) + private val _uiState = MutableStateFlow(ProviderSelectionUiState(providers = PROFILES)) val uiState: StateFlow = _uiState.asStateFlow() - fun select(option: ProviderOption) { - if (option.availability is Availability.Disabled || _uiState.value.isSelecting) return + fun select(profile: ProviderProfile) { + if (_uiState.value.isSelecting) return viewModelScope.launch { _uiState.value = _uiState.value.copy(isSelecting = true, errorMessage = null) - val result = coordinator.selectProvider(option.profile) + val result = coordinator.selectProvider(profile) _uiState.value = _uiState.value.copy( isSelecting = false, - errorMessage = (result as? com.flagship.abox.manager.domain.DomainResult.Failure) - ?.error?.detail, + errorMessage = (result as? DomainResult.Failure)?.error?.detail, ) } } @@ -53,12 +46,7 @@ class ProviderSelectionViewModel @Inject constructor( } data class ProviderSelectionUiState( - val providers: List, + val providers: List, val isSelecting: Boolean = false, val errorMessage: String? = null, ) - -data class ProviderOption( - val profile: ProviderProfile, - val availability: Availability, -) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt index 7e67424..12f57aa 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -8,8 +8,6 @@ import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.SessionCoordinator -import com.flagship.abox.manager.ui.provider.Availability -import com.flagship.abox.manager.ui.provider.ProviderAvailability import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -25,7 +23,6 @@ import kotlinx.coroutines.launch class AppSessionViewModel @Inject constructor( private val coordinator: SessionCoordinator, private val profileStore: ProviderProfileStore, - private val availability: ProviderAvailability, ) : ViewModel() { private val bootstrapResult = MutableStateFlow(null) @@ -50,18 +47,10 @@ class AppSessionViewModel @Inject constructor( viewModelScope.launch { try { val activeProfile = profileStore.activeProfile() - if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) { - coordinator.switchProvider() - bootstrapResult.value = ProviderBootstrapResult( - profileId = null, - result = DomainResult.Success(null), - ) - } else { - bootstrapResult.value = ProviderBootstrapResult( - profileId = activeProfile?.id, - result = coordinator.bootstrap(), - ) - } + bootstrapResult.value = ProviderBootstrapResult( + profileId = activeProfile?.id, + result = coordinator.bootstrap(), + ) } catch (error: kotlinx.coroutines.CancellationException) { throw error } catch (error: Exception) { @@ -110,7 +99,6 @@ class AppSessionViewModel @Inject constructor( bootstrapError: String?, ): AppSessionUiState = when { profile == null -> AppSessionUiState.ProviderSelection - availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile) else -> AppSessionUiState.Login(profile, bootstrapError) } diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 90e8af8..28b7def 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -3,15 +3,9 @@ 选择提供方 选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。 ABox - 通过兼容 ABSDK 的 ABox 服务连接设备。 - 自有兼容后端 - 在协议准备完成后连接自行部署的 ABSDK 兼容服务。 + 通过 ABSDK 连接当前局域网中的 ABox 智能家居网关。 本地体验 - 无需网络,通过本地模拟设备体验完整应用流程。 - 可用 - 开发模拟 - 协议待确认 - 等待传输安全方案批准 + 无需网络,通过本地模拟设备体验应用。 登录 %1$s 当前提供方:%1$s 返回提供方选择 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a2b1d2d..291774e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,15 +3,9 @@ Choose a provider Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers. ABox - Connect through the ABSDK-compatible ABox service. - Compatible backend - Use a self-hosted ABSDK-compatible service when its protocol is ready. + Connect to the ABox smart-home gateway through ABSDK on the current local network. Local experience - Explore the complete app flow with local simulated devices and no network. - Available - Development simulation - Protocol pending - Transport security approval required + Explore the app with simulated local devices and no network connection. Sign in to %1$s Selected provider: %1$s Back to providers diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt new file mode 100644 index 0000000..c485006 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/testing/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package com.flagship.abox.manager.testing + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val dispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt new file mode 100644 index 0000000..ca3ed31 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModelTest.kt @@ -0,0 +1,49 @@ +package com.flagship.abox.manager.ui.provider + +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.testing.MainDispatcherRule +import com.flagship.abox.manager.testing.fakes.FakeCredentialStore +import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway +import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import com.flagship.abox.manager.testing.fakes.FakeSessionGateway +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class ProviderSelectionViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val coordinator = SessionCoordinator( + profileStore = FakeProviderProfileStore(), + credentialStore = FakeCredentialStore(), + remoteGateway = FakeSessionGateway(), + localGateway = FakeLocalSessionGateway(), + ) + + @Test + fun exposesExactlyAboxAndLocalExperience() { + val viewModel = ProviderSelectionViewModel(coordinator) + + assertEquals( + listOf(ProviderType.ABOX, ProviderType.LOCAL_EXPERIENCE), + viewModel.uiState.value.providers.map { it.type }, + ) + } + + @Test + fun selectingLocalExperienceEstablishesSessionImmediately() = runTest { + val viewModel = ProviderSelectionViewModel(coordinator) + val local = viewModel.uiState.value.providers + .single { it.type == ProviderType.LOCAL_EXPERIENCE } + + viewModel.select(local) + advanceUntilIdle() + + assertEquals(local.id, coordinator.observeActiveSession().first()?.profileId) + } +}