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
+32
View File
@@ -0,0 +1,32 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.flagship.abox.manager.preferences"
compileSdk = 37
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.datastore.preferences)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,115 @@
package com.flagship.abox.manager.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
private const val UI_PREFERENCES_DATA_STORE_NAME = "ui_preferences"
private val Context.uiPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(
name = UI_PREFERENCES_DATA_STORE_NAME,
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
class DataStoreUiPreferencesRepository internal constructor(
private val dataStore: DataStore<Preferences>,
) : UiPreferencesRepository {
constructor(context: Context) : this(context.uiPreferencesDataStore)
override fun observe(): Flow<UiPreferences> = dataStore.data
.map(::preferencesFrom)
.catch { cause ->
if (cause is CancellationException) throw cause
emit(UiPreferences())
}
override suspend fun update(
transform: (UiPreferences) -> UiPreferences,
): UiPreferencesWriteResult = try {
dataStore.edit { storedPreferences ->
val updatedPreferences = try {
transform(preferencesFrom(storedPreferences))
} catch (cause: Throwable) {
throw TransformFailure(cause)
}
storedPreferences.putAll(updatedPreferences)
}
UiPreferencesWriteResult.Success
} catch (failure: TransformFailure) {
throw failure.cause
} catch (cause: CancellationException) {
throw cause
} catch (_: IOException) {
UiPreferencesWriteResult.Failure
}
private fun preferencesFrom(preferences: Preferences): UiPreferences = UiPreferences(
renderer = preferences[RENDERER]?.enumOrDefault(DesignRenderer.MATERIAL3)
?: DesignRenderer.MATERIAL3,
themeMode = preferences[THEME_MODE]?.enumOrDefault(AppThemeMode.SYSTEM)
?: AppThemeMode.SYSTEM,
dynamicColorEnabled = preferences[DYNAMIC_COLOR] ?: true,
blurEnabled = preferences[BLUR] ?: false,
navigationPresentation = preferences[NAVIGATION]?.enumOrDefault(NavigationPresentation.STANDARD)
?: NavigationPresentation.STANDARD,
hapticsEnabled = preferences[HAPTICS] ?: true,
interfaceScale = preferences[INTERFACE_SCALE]?.enumOrDefault(InterfaceScale.STANDARD)
?: InterfaceScale.STANDARD,
accentPreference = preferences[ACCENT]?.enumOrDefault(AccentPreference.BLUE)
?: AccentPreference.BLUE,
colorStyle = preferences[COLOR_STYLE]?.enumOrDefault(ColorStyle.TONAL_SPOT)
?: ColorStyle.TONAL_SPOT,
colorStandard = preferences[COLOR_STANDARD]?.enumOrDefault(ColorStandard.SPEC_2021)
?: ColorStandard.SPEC_2021,
liquidGlassEnabled = preferences[LIQUID_GLASS] ?: false,
predictiveBackEnabled = preferences[PREDICTIVE_BACK] ?: false,
)
private fun MutablePreferences.putAll(preferences: UiPreferences) {
this[RENDERER] = preferences.renderer.name
this[THEME_MODE] = preferences.themeMode.name
this[DYNAMIC_COLOR] = preferences.dynamicColorEnabled
this[BLUR] = preferences.blurEnabled
this[NAVIGATION] = preferences.navigationPresentation.name
this[HAPTICS] = preferences.hapticsEnabled
this[INTERFACE_SCALE] = preferences.interfaceScale.name
this[ACCENT] = preferences.accentPreference.name
this[COLOR_STYLE] = preferences.colorStyle.name
this[COLOR_STANDARD] = preferences.colorStandard.name
this[LIQUID_GLASS] = preferences.liquidGlassEnabled
this[PREDICTIVE_BACK] = preferences.predictiveBackEnabled
}
private inline fun <reified T : Enum<T>> String.enumOrDefault(default: T): T =
enumValues<T>().firstOrNull { it.name == this } ?: default
private class TransformFailure(
override val cause: Throwable,
) : RuntimeException(cause)
private companion object {
val RENDERER = stringPreferencesKey("renderer")
val THEME_MODE = stringPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val BLUR = booleanPreferencesKey("blur")
val NAVIGATION = stringPreferencesKey("navigation")
val HAPTICS = booleanPreferencesKey("haptics")
val INTERFACE_SCALE = stringPreferencesKey("interface_scale")
val ACCENT = stringPreferencesKey("accent")
val COLOR_STYLE = stringPreferencesKey("color_style")
val COLOR_STANDARD = stringPreferencesKey("color_standard")
val LIQUID_GLASS = booleanPreferencesKey("liquid_glass")
val PREDICTIVE_BACK = booleanPreferencesKey("predictive_back")
}
}
@@ -0,0 +1,72 @@
package com.flagship.abox.manager.preferences
enum class DesignRenderer {
MATERIAL3,
MIUIX,
}
enum class AppThemeMode {
SYSTEM,
LIGHT,
DARK,
}
enum class NavigationPresentation {
STANDARD,
FLOATING,
}
enum class InterfaceScale(val percent: Int) {
COMPACT(85),
STANDARD(100),
LARGE(115),
}
enum class AccentPreference {
BLUE,
GREEN,
AMBER,
ROSE,
VIOLET,
}
enum class ColorStyle {
TONAL_SPOT,
EXPRESSIVE,
FIDELITY,
}
enum class ColorStandard {
SPEC_2021,
SPEC_2025,
}
enum class PreferenceField {
RENDERER,
THEME_MODE,
DYNAMIC_COLOR,
BLUR,
NAVIGATION,
HAPTICS,
INTERFACE_SCALE,
ACCENT,
COLOR_STYLE,
COLOR_STANDARD,
LIQUID_GLASS,
PREDICTIVE_BACK,
}
data class UiPreferences(
val renderer: DesignRenderer = DesignRenderer.MATERIAL3,
val themeMode: AppThemeMode = AppThemeMode.SYSTEM,
val dynamicColorEnabled: Boolean = true,
val blurEnabled: Boolean = false,
val navigationPresentation: NavigationPresentation = NavigationPresentation.STANDARD,
val hapticsEnabled: Boolean = true,
val interfaceScale: InterfaceScale = InterfaceScale.STANDARD,
val accentPreference: AccentPreference = AccentPreference.BLUE,
val colorStyle: ColorStyle = ColorStyle.TONAL_SPOT,
val colorStandard: ColorStandard = ColorStandard.SPEC_2021,
val liquidGlassEnabled: Boolean = false,
val predictiveBackEnabled: Boolean = false,
)
@@ -0,0 +1,16 @@
package com.flagship.abox.manager.preferences
import kotlinx.coroutines.flow.Flow
sealed interface UiPreferencesWriteResult {
data object Success : UiPreferencesWriteResult
data object Failure : UiPreferencesWriteResult
}
interface UiPreferencesRepository {
fun observe(): Flow<UiPreferences>
suspend fun update(
transform: (UiPreferences) -> UiPreferences,
): UiPreferencesWriteResult
}
@@ -0,0 +1,181 @@
package com.flagship.abox.manager.preferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class DataStoreUiPreferencesRepositoryTest {
@Test
fun defaultsAreEmitted() = runTest {
val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore())
assertEquals(UiPreferences(), repository.observe().first())
}
@Test
fun eachFieldPersistsIndependently() = runTest {
val dataStore = InMemoryPreferencesDataStore()
val repository = DataStoreUiPreferencesRepository(dataStore)
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(renderer = DesignRenderer.MIUIX)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(themeMode = AppThemeMode.DARK)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(dynamicColorEnabled = false)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(blurEnabled = true)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(navigationPresentation = NavigationPresentation.FLOATING)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(hapticsEnabled = false)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(interfaceScale = InterfaceScale.LARGE)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(accentPreference = AccentPreference.GREEN)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(colorStyle = ColorStyle.EXPRESSIVE)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(colorStandard = ColorStandard.SPEC_2025)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(liquidGlassEnabled = true)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(predictiveBackEnabled = false)
})
assertEquals(
UiPreferences(
renderer = DesignRenderer.MIUIX,
themeMode = AppThemeMode.DARK,
dynamicColorEnabled = false,
blurEnabled = true,
navigationPresentation = NavigationPresentation.FLOATING,
hapticsEnabled = false,
interfaceScale = InterfaceScale.LARGE,
accentPreference = AccentPreference.GREEN,
colorStyle = ColorStyle.EXPRESSIVE,
colorStandard = ColorStandard.SPEC_2025,
liquidGlassEnabled = true,
predictiveBackEnabled = false,
),
repository.observe().first(),
)
}
@Test
fun unknownEnumFallsBackToDefault() = runTest {
val dataStore = InMemoryPreferencesDataStore()
dataStore.edit { preferences ->
preferences[stringPreferencesKey("renderer")] = "RETIRED_STYLE"
}
val repository = DataStoreUiPreferencesRepository(dataStore)
assertEquals(DesignRenderer.MATERIAL3, repository.observe().first().renderer)
}
@Test
fun corruptDataFallsBackWithoutThrowing() = runTest {
val repository = DataStoreUiPreferencesRepository(CorruptPreferencesDataStore())
assertEquals(UiPreferences(), repository.observe().first())
}
@Test
fun updateReturnsFailureWhenStorageWriteFails() = runTest {
val repository = DataStoreUiPreferencesRepository(FailingWritePreferencesDataStore())
assertEquals(
UiPreferencesWriteResult.Failure,
repository.update { it.copy(blurEnabled = true) },
)
}
@Test
fun updateRethrowsTransformExceptions() = runTest {
val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore())
val failure = IllegalStateException("invalid transform")
val actual = runCatching {
repository.update { throw failure }
}.exceptionOrNull()
assertEquals(failure, actual)
}
@Test
fun updateRethrowsTransformIOException() = runTest {
val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore())
val failure = IOException("invalid transform")
val actual = runCatching {
repository.update { throw failure }
}.exceptionOrNull()
assertEquals(failure, actual)
}
@Test
fun updateRethrowsCancellationException() = runTest {
val cancellation = CancellationException("cancelled")
val repository = DataStoreUiPreferencesRepository(
FailingWritePreferencesDataStore(cancellation),
)
val actual = runCatching {
repository.update { it.copy(blurEnabled = true) }
}.exceptionOrNull()
assertEquals(cancellation, actual)
}
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = transform(state.value).also(state::value::set)
}
private class CorruptPreferencesDataStore : DataStore<Preferences> {
override val data: Flow<Preferences> = kotlinx.coroutines.flow.flow {
throw IOException("corrupt preferences")
}
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = error("not used")
}
private class FailingWritePreferencesDataStore(
private val failure: Throwable = IOException("write failed"),
) : DataStore<Preferences> {
override val data: Flow<Preferences> = MutableStateFlow(emptyPreferences())
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = throw failure
}
}