feat(ui): complete ABox app experience redesign #10
@@ -66,6 +66,7 @@ kapt {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(project(":android:core:designsystem"))
|
implementation(project(":android:core:designsystem"))
|
||||||
|
implementation(project(":android:core:preferences"))
|
||||||
implementation(project(":android:core:session"))
|
implementation(project(":android:core:session"))
|
||||||
implementation(project(":android:feature:infrared"))
|
implementation(project(":android:feature:infrared"))
|
||||||
implementation(project(":absdk-bridge"))
|
implementation(project(":absdk-bridge"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
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 ->
|
||||||
|
storedPreferences.putAll(transform(preferencesFrom(storedPreferences)))
|
||||||
|
}
|
||||||
|
UiPreferencesWriteResult.Success
|
||||||
|
} 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T : Enum<T>> String.enumOrDefault(default: T): T =
|
||||||
|
enumValues<T>().firstOrNull { it.name == this } ?: default
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.flagship.abox.manager.preferences
|
||||||
|
|
||||||
|
enum class DesignRenderer {
|
||||||
|
MATERIAL3,
|
||||||
|
MIUIX,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AppThemeMode {
|
||||||
|
SYSTEM,
|
||||||
|
LIGHT,
|
||||||
|
DARK,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class NavigationPresentation {
|
||||||
|
STANDARD,
|
||||||
|
FLOATING,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class PreferenceField {
|
||||||
|
RENDERER,
|
||||||
|
THEME_MODE,
|
||||||
|
DYNAMIC_COLOR,
|
||||||
|
BLUR,
|
||||||
|
NAVIGATION,
|
||||||
|
HAPTICS,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
+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
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
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(
|
||||||
|
UiPreferences(
|
||||||
|
renderer = DesignRenderer.MIUIX,
|
||||||
|
themeMode = AppThemeMode.DARK,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
blurEnabled = true,
|
||||||
|
navigationPresentation = NavigationPresentation.FLOATING,
|
||||||
|
hapticsEnabled = 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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ include(":runtime:simulation")
|
|||||||
include(":absdk-bridge")
|
include(":absdk-bridge")
|
||||||
include(":testing:fakes")
|
include(":testing:fakes")
|
||||||
include(":android:core:designsystem")
|
include(":android:core:designsystem")
|
||||||
|
include(":android:core:preferences")
|
||||||
include(":android:core:session")
|
include(":android:core:session")
|
||||||
include(":android:feature:infrared")
|
include(":android:feature:infrared")
|
||||||
include(":android:app")
|
include(":android:app")
|
||||||
|
|||||||
Reference in New Issue
Block a user