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,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)
|
||||
}
|
||||
+115
@@ -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")
|
||||
}
|
||||
}
|
||||
+72
@@ -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,
|
||||
)
|
||||
+16
@@ -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
|
||||
}
|
||||
+181
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user