feat: add provider login flow #4
+10
-12
@@ -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<ComponentActivity>()
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
-27
@@ -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,
|
||||
}
|
||||
+19
-58
@@ -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,26 +151,14 @@ 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,
|
||||
"›",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text("›", style = MaterialTheme.typography.headlineSmall, color = statusColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+7
-19
@@ -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<ProviderSelectionUiState> = _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,
|
||||
|
pr-agent-bot
commented
发布门禁
**发布门禁**
`PROFILES` 在 main 源集中无条件包含可选择的 ABox,未使用构建变体或提供方可用性配置。release 用户因此仍可选择 ABox、持久化该配置并进入远程登录流程,与“release ABox 保持禁用”的约束不符。应注入按变体区分的可用提供方列表。
<!-- pr-agent-dedup: 44a9bd7b5849 -->
<!-- pr-agent-key-issue-location: 89961ce39261 -->
|
||||
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<ProviderOption>,
|
||||
val providers: List<ProviderProfile>,
|
||||
val isSelecting: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
data class ProviderOption(
|
||||
val profile: ProviderProfile,
|
||||
val availability: Availability,
|
||||
)
|
||||
|
||||
-12
@@ -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<ProviderBootstrapResult?>(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(),
|
||||
)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,9 @@
|
||||
<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_abox_description">通过 ABSDK 连接当前局域网中的 ABox 智能家居网关。</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="provider_local_description">无需网络,通过本地模拟设备体验应用。</string>
|
||||
<string name="login_title">登录 %1$s</string>
|
||||
<string name="login_provider_context">当前提供方:%1$s</string>
|
||||
<string name="login_back_to_providers">返回提供方选择</string>
|
||||
|
||||
@@ -3,15 +3,9 @@
|
||||
<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_abox_description">Connect to the ABox smart-home gateway through ABSDK on the current local network.</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="provider_local_description">Explore the app with simulated local devices and no network connection.</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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user
测试编译
assertDoesNotExist是 Compose 测试包中的顶层扩展,但当前文件未导入它,androidTest 源集编译时会出现未解析引用。请添加androidx.compose.ui.test.assertDoesNotExist导入。