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:
2026-08-27 23:25:07 +08:00
committed by KremeCN
parent 1570a95f30
commit 86b7e5849b
194 changed files with 11198 additions and 1444 deletions
@@ -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,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
}
@@ -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,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,
)
@@ -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
}