feat(ui): complete ABox app experience redesign (#10)
## Summary - rebuild the authenticated shell, device dashboard, device detail pages, scenes, activity timeline, account sheet, and settings experience - redesign infrared remote management and controls, including custom remotes, learn-state handling, slot isolation, and safe default content - add persisted theme controls with internal accent, color-style, and color-standard resolution while keeping dynamic color authoritative - add ABox profile defaults for the three exact gateway device names, editable starter scenes, and custom infrared remotes - persist real activity events for device commands, infrared actions, and scene execution - stabilize navigation and back behavior, including duplicate top-back protection and removal of the unfinished predictive-back option - update launcher icons and bilingual application name ## Verification - `:android:app:assembleDebug` - installed the debug APK on `emulator-5554` - manually exercised device detail navigation, settings/theme navigation, infrared management, activity records, and repeated back actions ## Notes - no automated test suite was run during the visual iteration cycle - `docs/superpowers/**` and local reference repositories are excluded from this PR Reviewed-on: #10 Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #10.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+57
@@ -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)
|
||||
}
|
||||
}
|
||||
+27
@@ -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))
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
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
|
||||
|
||||
@Query("UPDATE configured_devices SET id = :newId, deviceName = :newName WHERE profileId = :profileId AND deviceName = :oldName")
|
||||
suspend fun rename(profileId: String, oldName: String, newName: String, newId: String): Int
|
||||
}
|
||||
+16
@@ -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,
|
||||
)
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
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>
|
||||
suspend fun rename(profileId: ProviderProfileId, oldName: DeviceName, newName: DeviceName): DomainResult<Unit> =
|
||||
DomainResult.Failure(DomainError.InvalidRequest(detail = "Device rename is not supported"))
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
override suspend fun rename(profileId: ProviderProfileId, oldName: DeviceName, newName: DeviceName): DomainResult<Unit> {
|
||||
if (newName.value.isBlank()) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name is required"))
|
||||
return try {
|
||||
val changed = dao.rename(profileId.value, oldName.value, newName.value, "${profileId.value}:${newName.value}")
|
||||
if (changed == 1) DomainResult.Success(Unit)
|
||||
else DomainResult.Failure(DomainError.InvalidRequest(detail = "Device was not found"))
|
||||
} catch (error: Exception) {
|
||||
DomainResult.Failure(DomainError.InvalidRequest(detail = error.message ?: "Duplicate device name"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
+9
@@ -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
|
||||
}
|
||||
+31
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user