refactor: offer ABox and local experience providers

This commit is contained in:
2026-08-24 20:07:10 +08:00
parent 45f768208f
commit 4121836947
10 changed files with 117 additions and 159 deletions
@@ -2,7 +2,6 @@ package com.flagship.abox.manager.ui.provider
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
@@ -22,27 +21,26 @@ class ProviderSelectionScreenTest {
val composeRule = createAndroidComposeRule<ComponentActivity>() val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun enabledOptionCanBeSelectedAndDisabledOptionCannot() { fun aboxAndLocalExperienceAreBothSelectableAndNoCompatibleOptionIsShown() {
val local = ProviderOption( val abox = ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox")
ProviderProfile(ProviderProfileId("local"), ProviderType.LOCAL_EXPERIENCE, "Local"), val local = ProviderProfile(
Availability.Enabled, ProviderProfileId("local"),
ProviderType.LOCAL_EXPERIENCE,
"Local",
) )
val abox = ProviderOption( var selected: ProviderProfile? = null
ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox"),
Availability.Disabled(UnavailableReason.PROTOCOL_PENDING),
)
var selected: ProviderOption? = null
composeRule.setContent { composeRule.setContent {
ABoxTheme { ABoxTheme {
ProviderSelectionScreen( ProviderSelectionScreen(
state = ProviderSelectionUiState(listOf(local, abox)), state = ProviderSelectionUiState(listOf(abox, local)),
onSelect = { selected = it }, onSelect = { selected = it },
) )
} }
} }
composeRule.onNodeWithText("ABox").assertIsEnabled()
composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick() composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick()
assertEquals(local, selected) assertEquals(local, selected)
composeRule.onNodeWithText("ABox").assertIsNotEnabled() composeRule.onNodeWithText("Compatible backend").assertDoesNotExist()
} }
} }
@@ -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.ProviderProfileStoreImpl
import com.flagship.abox.manager.session.profile.sessionDataStore import com.flagship.abox.manager.session.profile.sessionDataStore
import com.flagship.abox.manager.simulation.SimulationSessionGateway 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.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -67,13 +64,6 @@ object SessionModule {
@Singleton @Singleton
fun provideLocalSessionGateway(): LocalSessionGateway = SimulationSessionGateway() fun provideLocalSessionGateway(): LocalSessionGateway = SimulationSessionGateway()
@Provides
@Singleton
fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability(
abox = Availability.Enabled,
compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING),
)
@Provides @Provides
@Singleton @Singleton
fun provideSessionCoordinator( fun provideSessionCoordinator(
@@ -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,
}
@@ -24,7 +24,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
@@ -36,6 +35,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.ProviderType
@Composable @Composable
@@ -50,7 +50,7 @@ fun ProviderSelectionRoute(
@Composable @Composable
fun ProviderSelectionScreen( fun ProviderSelectionScreen(
state: ProviderSelectionUiState, state: ProviderSelectionUiState,
onSelect: (ProviderOption) -> Unit, onSelect: (ProviderProfile) -> Unit,
) { ) {
Scaffold( Scaffold(
topBar = { topBar = {
@@ -77,9 +77,9 @@ fun ProviderSelectionScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 6.dp), modifier = Modifier.padding(bottom = 6.dp),
) )
state.providers.forEach { option -> state.providers.forEach { profile ->
ProviderOptionCard( ProviderOptionCard(
option = option, profile = profile,
selecting = state.isSelecting, selecting = state.isSelecting,
onSelect = onSelect, onSelect = onSelect,
) )
@@ -102,33 +102,25 @@ fun ProviderSelectionScreen(
@Composable @Composable
private fun ProviderOptionCard( private fun ProviderOptionCard(
option: ProviderOption, profile: ProviderProfile,
selecting: Boolean, selecting: Boolean,
onSelect: (ProviderOption) -> Unit, onSelect: (ProviderProfile) -> Unit,
) { ) {
val enabled = option.availability !is Availability.Disabled && !selecting val displayName = providerName(profile.type)
val displayName = providerName(option.profile.type) val description = providerDescription(profile.type)
val description = providerDescription(option.profile.type)
val status = availabilityLabel(option.availability)
val statusColor = availabilityColor(option.availability)
OutlinedCard( OutlinedCard(
onClick = { onSelect(option) }, onClick = { onSelect(profile) },
enabled = enabled, enabled = !selecting,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
colors = CardDefaults.outlinedCardColors( colors = CardDefaults.outlinedCardColors(
containerColor = when (option.availability) { containerColor = MaterialTheme.colorScheme.surface,
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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.alpha(if (enabled || selecting) 1f else 0.72f)
.semantics { .semantics {
role = Role.Button role = Role.Button
contentDescription = "$displayName, $status" contentDescription = displayName
}, },
) { ) {
Row( Row(
@@ -140,13 +132,13 @@ private fun ProviderOptionCard(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(statusColor.copy(alpha = 0.16f)), .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = providerMark(option.profile.type), text = providerMark(profile.type),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = statusColor, color = MaterialTheme.colorScheme.primary,
) )
} }
Column( Column(
@@ -159,24 +151,12 @@ private fun ProviderOptionCard(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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.ABOX -> "A"
ProviderType.LOCAL_EXPERIENCE -> "L" 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
}
@@ -2,6 +2,7 @@ package com.flagship.abox.manager.ui.provider
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.ProviderType
@@ -16,26 +17,18 @@ import kotlinx.coroutines.launch
@HiltViewModel @HiltViewModel
class ProviderSelectionViewModel @Inject constructor( class ProviderSelectionViewModel @Inject constructor(
private val coordinator: SessionCoordinator, private val coordinator: SessionCoordinator,
availability: ProviderAvailability,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(ProviderSelectionUiState(providers = PROFILES))
ProviderSelectionUiState(
providers = PROFILES.map { profile ->
ProviderOption(profile, availability.forType(profile.type))
},
),
)
val uiState: StateFlow<ProviderSelectionUiState> = _uiState.asStateFlow() val uiState: StateFlow<ProviderSelectionUiState> = _uiState.asStateFlow()
fun select(option: ProviderOption) { fun select(profile: ProviderProfile) {
if (option.availability is Availability.Disabled || _uiState.value.isSelecting) return if (_uiState.value.isSelecting) return
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSelecting = true, errorMessage = null) _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( _uiState.value = _uiState.value.copy(
isSelecting = false, isSelecting = false,
errorMessage = (result as? com.flagship.abox.manager.domain.DomainResult.Failure) errorMessage = (result as? DomainResult.Failure)?.error?.detail,
?.error?.detail,
) )
} }
} }
@@ -53,12 +46,7 @@ class ProviderSelectionViewModel @Inject constructor(
} }
data class ProviderSelectionUiState( data class ProviderSelectionUiState(
val providers: List<ProviderOption>, val providers: List<ProviderProfile>,
val isSelecting: Boolean = false, val isSelecting: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
) )
data class ProviderOption(
val profile: ProviderProfile,
val availability: Availability,
)
@@ -8,8 +8,6 @@ import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionCoordinator 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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,7 +23,6 @@ import kotlinx.coroutines.launch
class AppSessionViewModel @Inject constructor( class AppSessionViewModel @Inject constructor(
private val coordinator: SessionCoordinator, private val coordinator: SessionCoordinator,
private val profileStore: ProviderProfileStore, private val profileStore: ProviderProfileStore,
private val availability: ProviderAvailability,
) : ViewModel() { ) : ViewModel() {
private val bootstrapResult = MutableStateFlow<ProviderBootstrapResult?>(null) private val bootstrapResult = MutableStateFlow<ProviderBootstrapResult?>(null)
@@ -50,18 +47,10 @@ class AppSessionViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
try { try {
val activeProfile = profileStore.activeProfile() val activeProfile = profileStore.activeProfile()
if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) { bootstrapResult.value = ProviderBootstrapResult(
coordinator.switchProvider() profileId = activeProfile?.id,
bootstrapResult.value = ProviderBootstrapResult( result = coordinator.bootstrap(),
profileId = null, )
result = DomainResult.Success(null),
)
} else {
bootstrapResult.value = ProviderBootstrapResult(
profileId = activeProfile?.id,
result = coordinator.bootstrap(),
)
}
} catch (error: kotlinx.coroutines.CancellationException) { } catch (error: kotlinx.coroutines.CancellationException) {
throw error throw error
} catch (error: Exception) { } catch (error: Exception) {
@@ -110,7 +99,6 @@ class AppSessionViewModel @Inject constructor(
bootstrapError: String?, bootstrapError: String?,
): AppSessionUiState = when { ): AppSessionUiState = when {
profile == null -> AppSessionUiState.ProviderSelection profile == null -> AppSessionUiState.ProviderSelection
availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection
session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile) session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile)
else -> AppSessionUiState.Login(profile, bootstrapError) else -> AppSessionUiState.Login(profile, bootstrapError)
} }
@@ -3,15 +3,9 @@
<string name="provider_selection_title">选择提供方</string> <string name="provider_selection_title">选择提供方</string>
<string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string> <string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
<string name="provider_abox_name">ABox</string> <string name="provider_abox_name">ABox</string>
<string name="provider_abox_description">通过兼容 ABSDK 的 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_name">本地体验</string>
<string name="provider_local_description">无需网络,通过本地模拟设备体验完整应用流程。</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_title">登录 %1$s</string>
<string name="login_provider_context">当前提供方:%1$s</string> <string name="login_provider_context">当前提供方:%1$s</string>
<string name="login_back_to_providers">返回提供方选择</string> <string name="login_back_to_providers">返回提供方选择</string>
+2 -8
View File
@@ -3,15 +3,9 @@
<string name="provider_selection_title">Choose a provider</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_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_name">ABox</string>
<string name="provider_abox_description">Connect through the ABSDK-compatible ABox service.</string> <string name="provider_abox_description">Connect to the ABox smart-home gateway through ABSDK on the current local network.</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_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_local_description">Explore the app with simulated local devices and no network connection.</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_title">Sign in to %1$s</string>
<string name="login_provider_context">Selected provider: %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_back_to_providers">Back to providers</string>
@@ -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()
}
}
@@ -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)
}
}