feat(rhema-code): add the colors setting with four palettes

The stored `colors` key names a palette — rhema (the default,
Jadeite), aquamarine, black-and-white, or dracula — and the
Appearance page of /settings cycles it live, like the theme and
icon toggles beside it. Each palette carries a dark and a light
appearance under the existing `theme` key; dracula's light side is
derived, the official palette ships only dark.

The appearance decision moves off token comparison: Theme now
answers is_dark() from the ground/text luminance, so palettes
beyond the original two get the right per-profile fallback tones.
This commit is contained in:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

@@ -0,0 +1,82 @@
package rs.rhema.phone.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.byteArrayPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import rs.rhema.phone.net.AgentIdentity
import rs.rhema.phone.net.ClawApi
import rs.rhema.phone.net.ClawResult
// The agent's face and name as the phone last saw them: the identity
// document plus the avatar bytes. Cached in DataStore so the last known
// identity renders before (and without) a network round trip — the chat
// screen must never fall back to a hardcoded "rhema" just because one
// refresh failed.
class AgentIdentityCache(
val identity: AgentIdentity? = null,
val avatar: ByteArray? = null,
)
class AgentIdentityRepository(
private val store: DataStore<Preferences>,
private val api: ClawApi,
private val json: Json,
) {
private object Keys {
val identityJson = stringPreferencesKey("agent_identity_json")
val avatarBytes = byteArrayPreferencesKey("agent_avatar_bytes")
}
private val refreshMutex = Mutex()
val cached: Flow<AgentIdentityCache> = store.data.map { prefs ->
val identity = prefs[Keys.identityJson]?.let { raw ->
runCatching { json.decodeFromString<AgentIdentity>(raw) }.getOrNull()
}
AgentIdentityCache(identity, prefs[Keys.avatarBytes])
}
// Fetches the identity and, when one is set, the avatar bytes. Every
// failure keeps the cache as it was: this is presentation state, not
// a result the UI owes the user an error for.
suspend fun refresh() = refreshMutex.withLock {
val identity = when (val result = api.agent()) {
is ClawResult.Ok -> result.value
else -> return@withLock
}
val avatar = if (identity.avatar) {
when (val result = api.agentAvatar()) {
is ClawResult.Ok -> result.value.takeIf { it.isNotEmpty() }
else -> null
}
} else {
null
}
val encoded = json.encodeToString(identity)
store.edit { prefs ->
if (prefs[Keys.identityJson] != encoded) prefs[Keys.identityJson] = encoded
val currentBytes = prefs[Keys.avatarBytes]
when {
// Rewrite only on change: the avatar can weigh megabytes and
// every refresh would otherwise rewrite it into the store.
identity.avatar && avatar != null ->
if (currentBytes?.contentEquals(avatar) != true) {
prefs[Keys.avatarBytes] = avatar
}
identity.avatar == false && currentBytes != null ->
prefs.remove(Keys.avatarBytes)
// The identity says an avatar exists but the fetch failed:
// the cached bytes stay until a fetch succeeds.
else -> Unit
}
}
}
}
@@ -0,0 +1,10 @@
package rs.rhema.phone.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
// One DataStore file holds everything the app persists; the two repositories
// in this package are views over it.
val Context.rhemaStore: DataStore<Preferences> by preferencesDataStore(name = "rhema")
@@ -0,0 +1,45 @@
package rs.rhema.phone.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import rs.rhema.phone.net.ClawEvent
// One session's cached transcript tail: the events this device has
// applied, persisted as they stream. Opening a session seeds the screen
// from here instead of watching the claw's whole replay again, and the
// stream opens with `total` as its `since` cursor so the claw sends only
// the events past the cache.
@Serializable
data class CachedSession(
// The session's full event count — the `since` cursor. Can exceed
// [events]: the list keeps only the newest [SESSION_WINDOW] events,
// the count counts from the session's first.
val total: Long = 0,
val events: List<ClawEvent> = emptyList(),
)
// How many events the cache keeps per session. A resident session runs
// for weeks; a bounded window keeps the DataStore write flat, and a tail
// is enough to paint the screen — the claw still holds the whole record.
const val SESSION_WINDOW = 500
class SessionCache(private val store: DataStore<Preferences>, private val json: Json) {
private fun key(sessionKey: String) = stringPreferencesKey("session_cache:$sessionKey")
/** The cached tail, or an empty one for a session never opened here. */
suspend fun get(sessionKey: String): CachedSession =
store.data.first()[key(sessionKey)]?.let { raw ->
runCatching { json.decodeFromString<CachedSession>(raw) }.getOrNull()
} ?: CachedSession()
suspend fun put(sessionKey: String, cache: CachedSession) {
store.edit { it[key(sessionKey)] = json.encodeToString(cache) }
}
}
@@ -0,0 +1,74 @@
package rs.rhema.phone.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
// The bearer token lives in plain app-private DataStore, not
// EncryptedSharedPreferences: androidx.security:security-crypto is an
// alpha-quality, effectively unmaintained artifact, and the keystore ceremony
// it wraps buys little against the actual threat (another app) since the OS
// sandbox already isolates the file. Trade-off: root or a backup extraction
// can read it; backup is disabled in the manifest.
class SettingsRepository(private val store: DataStore<Preferences>) {
data class Credentials(val baseUrl: String, val token: String) {
val configured: Boolean get() = baseUrl.isNotBlank()
}
private object Keys {
val baseUrl = stringPreferencesKey("claw_base_url")
val token = stringPreferencesKey("claw_token")
val deviceId = stringPreferencesKey("device_id")
}
val credentials: Flow<Credentials> =
store.data.map { Credentials(it[Keys.baseUrl].orEmpty(), it[Keys.token].orEmpty()) }
// True once a base URL is stored. The token is needed for /v1 but not for
// /healthz, so only the URL gates the first-launch settings screen.
val configured: Flow<Boolean> = credentials.map { it.configured }
// UI-facing id stream; workers use the suspend deviceId() below.
val deviceIdFlow: Flow<String> = store.data.map { it[Keys.deviceId].orEmpty() }
suspend fun current(): Credentials = credentials.first()
suspend fun setCredentials(rawBaseUrl: String, token: String) {
// Normalize the base: trim, drop trailing slashes, and drop a
// trailing `/v1` — the client appends the API paths itself, and a
// pasted `…/v1` would double-prefix every call into the UI's
// catch-all. Also downgrade the host spelling of a schemeless
// paste by prepending http, the self-hosted default.
val trimmed = rawBaseUrl.trim().trimEnd('/')
val base = if (trimmed.endsWith("/v1")) trimmed.removeSuffix("/v1") else trimmed
val baseUrl = when {
base.startsWith("http://") || base.startsWith("https://") -> base
base.isNotEmpty() -> "http://$base"
else -> base
}
store.edit {
it[Keys.baseUrl] = baseUrl
it[Keys.token] = token
}
}
// Generated once, then immutable for the life of the install; ingest keys
// and the notification cursor are namespaced by it.
suspend fun deviceId(): String {
store.data.first()[Keys.deviceId]?.takeIf { it.isNotBlank() }?.let { return it }
store.edit { prefs ->
if (prefs[Keys.deviceId].isNullOrBlank()) {
prefs[Keys.deviceId] = UUID.randomUUID().toString()
}
}
val id = store.data.first()[Keys.deviceId]
check(!id.isNullOrBlank())
return id
}
}
@@ -0,0 +1,62 @@
package rs.rhema.phone.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
// What the last successful phone-status run reported. A null field means the
// sensor was unreadable or ungranted on this device; null fields never produce
// ingest posts. New fields default to null, so a snapshot persisted by an
// older build decodes (and re-posts each new topic once, which is what a
// first report should do).
@Serializable
data class PhoneSnapshot(
val charging: Boolean? = null,
val batteryLevel: Int? = null,
val network: String? = null,
val dnd: String? = null,
// The last posted location, kept for the move-distance rule.
val locationLat: Double? = null,
val locationLon: Double? = null,
// The last posted foreground app package.
val app: String? = null,
// The last posted device-identity fingerprint.
val device: String? = null,
val wifi: String? = null,
// The next alarm's trigger time, epoch millis.
val alarm: Long? = null,
)
class StatusRepository(private val store: DataStore<Preferences>, private val json: Json) {
private object Keys {
val snapshot = stringPreferencesKey("last_phone_snapshot")
val notificationCursor = stringPreferencesKey("last_notification_id")
}
suspend fun lastSnapshot(): PhoneSnapshot? =
store.data.first()[Keys.snapshot]?.let { raw ->
runCatching { json.decodeFromString<PhoneSnapshot>(raw) }.getOrNull()
}
// Only called after every ingest post of a run succeeded, so a failed run
// retries the whole transition set next time instead of swallowing it.
suspend fun setSnapshot(snapshot: PhoneSnapshot) {
store.edit { it[Keys.snapshot] = json.encodeToString(snapshot) }
}
// Cursor into the claw's notification list: the last id this device showed.
val notificationCursor: Flow<String> =
store.data.map { it[Keys.notificationCursor].orEmpty() }
suspend fun setNotificationCursor(id: String) {
store.edit { it[Keys.notificationCursor] = id }
}
}