RFC-009: Settings
RFC-009: Settings
Document Information
- RFC ID: RFC-009
- Related PRD: FEAT-009 (Settings)
- Related Architecture: RFC-000 (Overall Architecture)
- Depends On: None (Settings screen already exists)
- Depended On By: RFC-006 (Token Tracking), FEAT-007 (Data Sync)
- Created: 2026-02-28
- Last Updated: 2026-02-28
- Status: Draft
- Author: TBD
Overview
Background
The Settings screen currently has two flat entries: “Manage Agents” and “Manage Providers”. As the app grows, more features (token tracking, data sync, appearance customization) will add their own entries to Settings. Without structure, the screen becomes a long unsorted list.
This RFC adds an Appearance section with theme control and reorganizes the Settings screen into labeled sections. Font size follows the Android system setting automatically (Compose sp units). Language is English only in V1.
The existing app_settings table (key-value store via SettingsDao) is the persistence layer for theme preference. The existing OneClawTheme composable already supports a darkTheme boolean parameter. The implementation bridges these two: read the stored preference at app start, apply it via AppCompatDelegate.setDefaultNightMode(), and provide a Compose-level darkTheme override so that OneClawTheme responds immediately without requiring an Activity recreation.
Goals
- Reorganize the Settings screen into labeled sections: Appearance, Providers & Models, Agents, Usage, Data & Backup.
- Add a Theme setting with three options: System default, Light, Dark.
- Theme change takes effect immediately (no app restart).
- Theme preference persists across app restarts.
- Font size follows the Android system setting (no code changes needed – Compose
spunits handle this).
Non-Goals
- In-app font size control (deferred to future version).
- Language selection / localization.
- Notification preferences.
- About / version info screen.
- Implementing the Usage or Data & Backup features (those are FEAT-006 and FEAT-007 – this RFC only adds placeholder entries).
Technical Design
Architecture Overview
Theme preference flow:
1. App Launch
OneclawApplication.onCreate()
-> SettingsRepository.getString("theme_mode")
-> AppCompatDelegate.setDefaultNightMode(mode)
-> ThemeManager.themeMode (StateFlow) initialized
2. Compose Theme
MainActivity.setContent
-> ThemeManager.themeMode collected as State
-> OneClawTheme(darkTheme = resolved from themeMode)
3. User Changes Theme (SettingsScreen)
User taps Theme -> AlertDialog with RadioButtons
-> ThemeManager.setThemeMode(newMode)
-> SettingsRepository.setString("theme_mode", newMode)
-> AppCompatDelegate.setDefaultNightMode(mode)
-> ThemeManager.themeMode updated -> Compose recomposes
No schema changes. The app_settings table already exists with key-value storage.
Change 1: ThemeManager
A singleton that holds the current theme mode as a StateFlow and provides methods to initialize and update it. It acts as the single source of truth for the theme state.
New file: core/theme/ThemeManager.kt
package com.oneclaw.shadow.core.theme
import androidx.appcompat.app.AppCompatDelegate
import com.oneclaw.shadow.core.repository.SettingsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
enum class ThemeMode(val key: String) {
SYSTEM("system"),
LIGHT("light"),
DARK("dark");
companion object {
fun fromKey(key: String?): ThemeMode = entries.find { it.key == key } ?: SYSTEM
}
}
class ThemeManager(
private val settingsRepository: SettingsRepository
) {
private val _themeMode = MutableStateFlow(ThemeMode.SYSTEM)
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
companion object {
const val SETTINGS_KEY = "theme_mode"
}
/**
* Called once during Application.onCreate() to read the persisted theme
* and apply it via AppCompatDelegate.
*/
suspend fun initialize() {
val stored = settingsRepository.getString(SETTINGS_KEY)
val mode = ThemeMode.fromKey(stored)
_themeMode.value = mode
applyNightMode(mode)
}
/**
* Called from the Settings screen when the user selects a new theme.
* Persists the choice, updates the StateFlow, and applies via AppCompatDelegate.
*/
suspend fun setThemeMode(mode: ThemeMode) {
settingsRepository.setString(SETTINGS_KEY, mode.key)
_themeMode.value = mode
applyNightMode(mode)
}
private fun applyNightMode(mode: ThemeMode) {
val nightMode = when (mode) {
ThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
ThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
ThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
}
AppCompatDelegate.setDefaultNightMode(nightMode)
}
}
Change 2: Register ThemeManager in DI and Initialize at App Start
2a. DI Registration
In AppModule.kt (or the appropriate DI module where singletons live), register ThemeManager as a singleton:
// In the existing appModule or a new themeModule
single { ThemeManager(get()) }
2b. Initialize in OneclawApplication
class OneclawApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger(Level.ERROR)
androidContext(this@OneclawApplication)
modules(
appModule,
databaseModule,
networkModule,
repositoryModule,
toolModule,
featureModule
)
}
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
get<CleanupSoftDeletedUseCase>(CleanupSoftDeletedUseCase::class.java)()
}
// RFC-009: Initialize theme from persisted setting
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
get<ThemeManager>(ThemeManager::class.java).initialize()
}
}
}
Change 3: Wire ThemeManager into Compose Theme
Update MainActivity to collect the ThemeManager.themeMode flow and pass the resolved darkTheme value to OneClawTheme:
class MainActivity : ComponentActivity() {
private val permissionChecker: PermissionChecker by inject()
private val themeManager: ThemeManager by inject()
// ... permissionLauncher unchanged ...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
permissionChecker.bindToActivity(permissionLauncher)
enableEdgeToEdge()
setContent {
val themeMode by themeManager.themeMode.collectAsState()
val darkTheme = when (themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
OneClawTheme(darkTheme = darkTheme) {
Surface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
AppNavGraph(navController = navController)
}
}
}
}
// ... onDestroy unchanged ...
}
This means theme changes via ThemeManager.setThemeMode() immediately update the StateFlow, which triggers a recomposition, which applies the new color scheme – no Activity restart needed.
Change 4: Reorganize SettingsScreen with Sections and Theme Dialog
The current SettingsScreen is a flat list. Replace it with a sectioned layout and add a theme selection dialog.
package com.oneclaw.shadow.feature.provider
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.oneclaw.shadow.core.theme.ThemeManager
import com.oneclaw.shadow.core.theme.ThemeMode
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
onManageProviders: () -> Unit,
onManageAgents: () -> Unit = {},
onUsageStatistics: () -> Unit = {},
themeManager: ThemeManager = koinInject()
) {
val currentTheme by themeManager.themeMode.collectAsState()
val scope = rememberCoroutineScope()
var showThemeDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
) {
// -- Appearance --
SectionHeader("Appearance")
SettingsItem(
title = "Theme",
subtitle = when (currentTheme) {
ThemeMode.SYSTEM -> "System default"
ThemeMode.LIGHT -> "Light"
ThemeMode.DARK -> "Dark"
},
onClick = { showThemeDialog = true }
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// -- Providers & Models --
SectionHeader("Providers & Models")
SettingsItem(
title = "Manage Providers",
subtitle = "Add API keys, configure models",
onClick = onManageProviders
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// -- Agents --
SectionHeader("Agents")
SettingsItem(
title = "Manage Agents",
subtitle = "Create and configure agents",
onClick = onManageAgents
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// -- Usage --
SectionHeader("Usage")
SettingsItem(
title = "Usage Statistics",
subtitle = "View token usage by model",
onClick = onUsageStatistics
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// -- Data & Backup (placeholder for FEAT-007) --
SectionHeader("Data & Backup")
SettingsItem(
title = "Google Drive Sync",
subtitle = "Not connected",
onClick = { /* TODO: FEAT-007 */ }
)
SettingsItem(
title = "Export Backup",
subtitle = "Save all data to a file",
onClick = { /* TODO: FEAT-007 */ }
)
SettingsItem(
title = "Import Backup",
subtitle = "Restore from a backup file",
onClick = { /* TODO: FEAT-007 */ }
)
}
}
// Theme selection dialog
if (showThemeDialog) {
ThemeSelectionDialog(
currentTheme = currentTheme,
onThemeSelected = { mode ->
scope.launch { themeManager.setThemeMode(mode) }
showThemeDialog = false
},
onDismiss = { showThemeDialog = false }
)
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp)
)
}
@Composable
private fun SettingsItem(
title: String,
subtitle: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun ThemeSelectionDialog(
currentTheme: ThemeMode,
onThemeSelected: (ThemeMode) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Theme") },
text = {
Column(modifier = Modifier.selectableGroup()) {
ThemeMode.entries.forEach { mode ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = mode == currentTheme,
onClick = { onThemeSelected(mode) },
role = Role.RadioButton
)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = mode == currentTheme,
onClick = null // handled by Row selectable
)
Text(
text = when (mode) {
ThemeMode.SYSTEM -> "System default"
ThemeMode.LIGHT -> "Light"
ThemeMode.DARK -> "Dark"
},
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
Key design decisions:
- The dialog dismisses on radio button selection (immediate apply). The “Cancel” button is for dismissing without changing.
SectionHeaderuseslabelLargestyle withprimarycolor, consistent with the PRD specification.- The
SettingsItemcomposable is reused from the existing code, unchanged. - The screen is wrapped in
verticalScrollto handle content that may exceed the viewport. - Usage and Data & Backup entries are placeholder – they call empty lambdas or
TODOblocks until FEAT-006 and FEAT-007 are wired.
Change 5: Update NavGraph
Pass the onUsageStatistics callback in the NavGraph:
composable(Route.Settings.path) {
SettingsScreen(
onNavigateBack = { navController.popBackStack() },
onManageProviders = { navController.navigate(Route.ProviderList.path) },
onManageAgents = { navController.navigate(Route.AgentList.path) },
onUsageStatistics = { navController.navigate(Route.UsageStatistics.path) }
)
}
Note: The Route.UsageStatistics route and its composable destination are defined in RFC-006. If RFC-006 is not yet implemented, onUsageStatistics can remain an empty lambda {} until then.
Implementation Steps
Step 1: Create ThemeManager
- File:
app/src/main/kotlin/com/oneclaw/shadow/core/theme/ThemeManager.kt(new) - Add
ThemeModeenum withSYSTEM,LIGHT,DARKandfromKey()companion function - Add
ThemeManagerclass withStateFlow<ThemeMode>,initialize(), andsetThemeMode()methods initialize()reads fromSettingsRepositoryand callsAppCompatDelegate.setDefaultNightMode()setThemeMode()persists toSettingsRepository, updates StateFlow, and callsAppCompatDelegate.setDefaultNightMode()
Step 2: Register ThemeManager in DI
- File:
app/src/main/kotlin/com/oneclaw/shadow/di/AppModule.kt(existing) - Add
single { ThemeManager(get()) }to the module
Step 3: Initialize theme at app start
- File:
app/src/main/kotlin/com/oneclaw/shadow/OneclawApplication.kt(existing) - In
onCreate(), after Koin initialization, launch a coroutine to callThemeManager.initialize()
Step 4: Wire ThemeManager into MainActivity
- File:
app/src/main/kotlin/com/oneclaw/shadow/MainActivity.kt(existing) - Inject
ThemeManagervia Koin - Collect
themeManager.themeModeas Compose state - Resolve
darkThemeboolean fromThemeModeand pass toOneClawTheme(darkTheme = ...)
Step 5: Reorganize SettingsScreen with sections and theme dialog
- File:
app/src/main/kotlin/com/oneclaw/shadow/feature/provider/SettingsScreen.kt(existing) - Add
SectionHeadercomposable (labelLarge,primarycolor) - Reorganize existing items under “Providers & Models” and “Agents” section headers
- Add “Appearance” section with “Theme” entry that opens
ThemeSelectionDialog - Add “Usage” section with “Usage Statistics” placeholder entry
- Add “Data & Backup” section with placeholder entries for Google Drive Sync, Export, Import
- Add
ThemeSelectionDialogcomposable withAlertDialog,RadioButtonfor eachThemeMode - Inject
ThemeManagerviakoinInject(), collectthemeModeto show current selection - On theme selection: call
themeManager.setThemeMode()in a coroutine scope, dismiss dialog - Wrap content in
verticalScrollto handle overflow
Step 6: Update NavGraph (if RFC-006 is implemented)
- File:
app/src/main/kotlin/com/oneclaw/shadow/navigation/NavGraph.kt(existing) - Pass
onUsageStatisticscallback toSettingsScreenthat navigates toRoute.UsageStatistics
Test Strategy
Layer 1A – Unit Tests
ThemeModeTest (app/src/test/kotlin/.../core/theme/):
- Test:
ThemeMode.fromKey("system")returnsThemeMode.SYSTEM - Test:
ThemeMode.fromKey("light")returnsThemeMode.LIGHT - Test:
ThemeMode.fromKey("dark")returnsThemeMode.DARK - Test:
ThemeMode.fromKey(null)returnsThemeMode.SYSTEM(default) - Test:
ThemeMode.fromKey("invalid")returnsThemeMode.SYSTEM(fallback)
ThemeManagerTest (app/src/test/kotlin/.../core/theme/):
- Test:
initialize()with no stored value setsthemeModetoSYSTEM - Test:
initialize()with stored"dark"setsthemeModetoDARK - Test:
initialize()with stored"light"setsthemeModetoLIGHT - Test:
setThemeMode(DARK)persists"dark"toSettingsRepositoryand updatesthemeModeflow toDARK - Test:
setThemeMode(LIGHT)afterinitialize()updates the flow from the initial value toLIGHT - Test:
initialize()callsAppCompatDelegate.setDefaultNightMode()with the correct mode constant
Layer 1C – Screenshot Tests
SettingsScreenwith sections visible: verify section headers (“Appearance”, “Providers & Models”, “Agents”, “Usage”, “Data & Backup”) are rendered withlabelLargestyle and primary colorSettingsScreenwith theme set to “System default”: verify “Theme” row shows “System default” subtitleSettingsScreenwith theme set to “Dark”: verify “Theme” row shows “Dark” subtitleThemeSelectionDialogopen with “System default” selected: verify three radio options, “System default” is checkedThemeSelectionDialogopen with “Dark” selected: verify “Dark” radio is checkedSettingsScreenin dark mode: verify dark color scheme is appliedSettingsScreenin light mode: verify light color scheme is applied
Layer 2 – adb Visual Verification
Flow 9-1: Change theme to Dark
- Open Settings
- Verify “Appearance” section header is visible
- Tap “Theme” – verify dialog opens with “System default” pre-selected
- Select “Dark” – verify dialog dismisses and app switches to dark theme immediately
- Navigate back to Chat screen – verify dark theme is applied
- Force-stop and relaunch the app – verify app launches in dark mode (preference persisted)
Flow 9-2: Change theme to Light
- Open Settings > tap “Theme”
- Select “Light” – verify app switches to light theme
- Change Android system to dark mode (via system Settings) – verify app stays in light mode (user override)
Flow 9-3: System default theme
- Open Settings > tap “Theme”, select “System default”
- Change Android system from light to dark mode – verify app switches to dark mode automatically
- Change Android system back to light mode – verify app switches back to light mode
Flow 9-4: Settings screen sections
- Open Settings
- Verify all section headers visible: Appearance, Providers & Models, Agents, Usage, Data & Backup
- Tap “Manage Providers” – verify navigation to provider list
- Go back, tap “Manage Agents” – verify navigation to agent list
- Go back, verify “Usage Statistics”, “Google Drive Sync”, “Export Backup”, “Import Backup” entries are visible
Flow 9-5: System font size
- In Android system Settings, set font size to “Largest”
- Open the app
- Verify all text (settings labels, section headers, dialog text) renders at the larger font size
- Set system font size back to default – verify app text returns to normal size
Data Flow
Theme Initialization (App Launch)
OneclawApplication.onCreate()
-> ThemeManager.initialize()
-> SettingsRepository.getString("theme_mode")
-> SettingsDao.getString("theme_mode")
-> SQL: SELECT value FROM app_settings WHERE key = 'theme_mode'
-> returns null (fresh install) or "system" / "light" / "dark"
-> ThemeMode.fromKey(stored) -> ThemeMode enum value
-> _themeMode.value = mode (StateFlow updated)
-> AppCompatDelegate.setDefaultNightMode(mapped mode constant)
Theme Change (User Action)
User taps Theme in SettingsScreen
-> showThemeDialog = true
-> ThemeSelectionDialog renders with currentTheme pre-selected
-> User taps "Dark" radio button
-> onThemeSelected(ThemeMode.DARK) called
-> scope.launch { themeManager.setThemeMode(ThemeMode.DARK) }
-> SettingsRepository.setString("theme_mode", "dark")
-> SettingsDao.set(SettingsEntity(key = "theme_mode", value = "dark"))
-> _themeMode.value = DARK (StateFlow updated)
-> AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_YES)
-> showThemeDialog = false (dialog dismisses)
-> MainActivity: themeMode collected as State -> darkTheme = true
-> OneClawTheme(darkTheme = true) recomposes with darkScheme
Compose Theme Resolution
MainActivity.setContent
-> val themeMode by themeManager.themeMode.collectAsState()
-> when (themeMode):
SYSTEM -> isSystemInDarkTheme() (delegates to Android system)
LIGHT -> false
DARK -> true
-> OneClawTheme(darkTheme = resolved)
-> MaterialTheme uses lightScheme or darkScheme accordingly
Change History
| Date | Version | Change | Author |
|---|---|---|---|
| 2026-02-28 | 0.1 | Initial draft | TBD |