feat(ui): complete ABox app experience redesign #10

Merged
KremeCN merged 40 commits from feat/ui-ux-redesign into main 2026-08-27 23:25:08 +08:00
12 changed files with 284 additions and 22 deletions
Showing only changes of commit e14b4cee27 - Show all commits
+1
View File
@@ -67,6 +67,7 @@ dependencies {
implementation(project(":domain"))
implementation(project(":android:core:designsystem"))
implementation(project(":android:core:preferences"))
implementation(project(":android:core:devicecatalog"))
implementation(project(":android:core:session"))
implementation(project(":android:feature:infrared"))
implementation(project(":absdk-bridge"))
@@ -1,22 +0,0 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object FoundationModule {
// TEMPORARY boundary: ABSDK has no device catalog, and the provider-scoped production
// ABox catalog is owned by the device-catalog module. Until that exists, debug builds
// keep the fake ABox catalog here. Do NOT route ABox catalog requests to
// SimulationDeviceCatalog; release builds must not request a DeviceCatalog binding
// until a real release consumer appears.
@Provides
@Singleton
fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog()
}
@@ -2,12 +2,16 @@ package com.flagship.abox.manager.di
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
import com.flagship.abox.manager.simulation.SimulationSocketGateway
import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway
import com.flagship.abox.manager.simulation.SimulationDeviceCatalog
import com.flagship.abox.manager.devicecatalog.RoutingDeviceCatalog
import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -22,6 +26,18 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DeviceGatewayModule {
@Provides
@Singleton
fun provideSimulationDeviceCatalog(): SimulationDeviceCatalog = SimulationDeviceCatalog()
@Provides
@Singleton
fun provideDeviceCatalog(
profileStore: ProviderProfileStore,
configured: RoomConfiguredDeviceRepository,
simulation: SimulationDeviceCatalog,
): DeviceCatalog = RoutingDeviceCatalog(profileStore, configured, simulation)
@Provides
@Singleton
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
@@ -0,0 +1,46 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt.android)
}
android {
namespace = "com.flagship.abox.manager.devicecatalog"
compileSdk = 37
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
kapt {
correctErrorTypes = true
arguments { arg("room.schemaLocation", "$projectDir/schemas") }
}
dependencies {
api(project(":domain"))
implementation(libs.room.runtime)
implementation(libs.room.ktx)
kapt(libs.room.compiler)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.room.testing)
androidTestImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,57 @@
package com.flagship.abox.manager.devicecatalog
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.devicecatalog.data.DeviceCatalogDatabase
import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomConfiguredDeviceCatalogTest {
private lateinit var database: DeviceCatalogDatabase
private lateinit var repository: RoomConfiguredDeviceRepository
@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), DeviceCatalogDatabase::class.java).build()
repository = RoomConfiguredDeviceRepository(database.configuredDeviceDao())
}
@After
fun tearDown() = database.close()
@Test
fun configuredDevicesAreIsolatedByProfile() = runTest {
repository.add(ProviderProfileId("one"), DeviceName("plug"), DeviceType.SOCKET)
val result = repository.observeDevices(ProviderProfileId("two")).first() as DomainResult.Success
assertTrue(result.value.isEmpty())
}
@Test
fun sameProfileRejectsDuplicateDeviceNameAcrossTypes() = runTest {
val profile = ProviderProfileId("one")
assertTrue(repository.add(profile, DeviceName("same"), DeviceType.SOCKET) is DomainResult.Success)
assertTrue(repository.add(profile, DeviceName("same"), DeviceType.INFRARED) is DomainResult.Failure)
}
@Test
fun removeEmitsUpdatedCatalog() = runTest {
val profile = ProviderProfileId("one")
repository.add(profile, DeviceName("plug"), DeviceType.SOCKET)
repository.remove(profile, DeviceName("plug"))
val result = repository.observeDevices(profile).first() as DomainResult.Success
assertEquals(emptyList<Any>(), result.value)
}
}
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.devicecatalog
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
class RoutingDeviceCatalog(
private val profileStore: ProviderProfileStore,
private val abox: DeviceCatalog,
private val local: DeviceCatalog,
) : DeviceCatalog {
override fun observeDevices(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> = flow {
val active = profileStore.activeProfile()
if (active?.id != profileId) {
emit(DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active")))
return@flow
}
emitAll(if (active.type == ProviderType.ABOX) abox.observeDevices(profileId) else local.observeDevices(profileId))
}
}
@@ -0,0 +1,19 @@
package com.flagship.abox.manager.devicecatalog.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ConfiguredDeviceDao {
@Query("SELECT * FROM configured_devices WHERE profileId = :profileId ORDER BY deviceName")
fun observe(profileId: String): Flow<List<ConfiguredDeviceEntity>>
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(device: ConfiguredDeviceEntity)
@Query("DELETE FROM configured_devices WHERE profileId = :profileId AND deviceName = :deviceName")
suspend fun remove(profileId: String, deviceName: String): Int
}
@@ -0,0 +1,16 @@
package com.flagship.abox.manager.devicecatalog.data
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "configured_devices",
indices = [Index(value = ["profileId", "deviceName"], unique = true)],
)
data class ConfiguredDeviceEntity(
@PrimaryKey val id: String,
val profileId: String,
val deviceName: String,
val deviceType: String,
)
@@ -0,0 +1,61 @@
package com.flagship.abox.manager.devicecatalog.data
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceId
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface ConfiguredDeviceRepository {
fun observe(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>>
suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult<DeviceDescriptor>
suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit>
}
class RoomConfiguredDeviceRepository(
private val dao: ConfiguredDeviceDao,
) : ConfiguredDeviceRepository, DeviceCatalog {
override fun observe(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> =
dao.observe(profileId.value).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) }
override fun observeDevices(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> = observe(profileId)
override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult<DeviceDescriptor> {
if (profileId.value.isBlank() || name.value.isBlank()) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Profile and device name are required"))
}
val descriptor = DeviceDescriptor(DeviceId("${profileId.value}:${name.value}"), profileId, name, type)
return try {
dao.insert(descriptor.toEntity())
DomainResult.Success(descriptor)
} catch (error: Exception) {
DomainResult.Failure(DomainError.InvalidRequest(detail = error.message ?: "Duplicate device name"))
}
}
override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit> = try {
dao.remove(profileId.value, deviceName.value)
DomainResult.Success(Unit)
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = error.message ?: "Unable to remove device"))
}
}
private fun ConfiguredDeviceEntity.toDomain() = DeviceDescriptor(
id = DeviceId(id),
profileId = ProviderProfileId(profileId),
name = DeviceName(deviceName),
type = DeviceType.valueOf(deviceType),
)
private fun DeviceDescriptor.toEntity() = ConfiguredDeviceEntity(
id = id.value,
profileId = profileId.value,
deviceName = name.value,
deviceType = type.name,
)
@@ -0,0 +1,9 @@
package com.flagship.abox.manager.devicecatalog.data
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [ConfiguredDeviceEntity::class], version = 1, exportSchema = false)
abstract class DeviceCatalogDatabase : RoomDatabase() {
abstract fun configuredDeviceDao(): ConfiguredDeviceDao
}
@@ -0,0 +1,31 @@
package com.flagship.abox.manager.devicecatalog.di
import android.content.Context
import androidx.room.Room
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.devicecatalog.data.DeviceCatalogDatabase
import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository
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 DeviceCatalogModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): DeviceCatalogDatabase =
Room.databaseBuilder(context, DeviceCatalogDatabase::class.java, "abox_devices.db").build()
@Provides
@Singleton
fun provideRepository(database: DeviceCatalogDatabase): RoomConfiguredDeviceRepository =
RoomConfiguredDeviceRepository(database.configuredDeviceDao())
@Provides
@Singleton
fun provideConfiguredDeviceRepository(repository: RoomConfiguredDeviceRepository): ConfiguredDeviceRepository = repository
}
+1
View File
@@ -32,6 +32,7 @@ include(":absdk-bridge")
include(":testing:fakes")
include(":android:core:designsystem")
include(":android:core:preferences")
include(":android:core:devicecatalog")
include(":android:core:session")
include(":android:feature:infrared")
include(":android:app")