Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ plugins {

kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

Expand Down Expand Up @@ -38,8 +38,8 @@ android {
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

buildFeatures {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ import android.util.Base64
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
Expand All @@ -41,6 +47,7 @@ class MainActivity : ComponentActivity() {

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private lateinit var codeobaApp: CodeobaApp
private lateinit var themePreferenceManager: ThemePreferenceManager

companion object {
private const val TAG = "MainActivity"
Expand Down Expand Up @@ -85,6 +92,12 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Enable edge-to-edge display
enableEdgeToEdge()

// Initialize theme preference manager
themePreferenceManager = ThemePreferenceManager(this)

// Request required permissions
requestRequiredPermissions()

Expand All @@ -105,7 +118,29 @@ class MainActivity : ComponentActivity() {
)

setContent {
MaterialTheme {
val themeMode = themePreferenceManager.currentThemeMode()
val isDarkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.SYSTEM -> isSystemInDarkTheme()
}

// Use dynamic colors on Android 12+ (API 31+), fallback to default color scheme
val colorScheme = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
if (isDarkTheme) {
dynamicDarkColorScheme(this)
} else {
dynamicLightColorScheme(this)
}
} else {
if (isDarkTheme) {
darkColorScheme()
} else {
lightColorScheme()
}
}

MaterialTheme(colorScheme = colorScheme) {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Expand All @@ -123,6 +158,18 @@ class MainActivity : ComponentActivity() {
CodeobaUI(
app = codeobaApp,
config = config,
currentThemeMode = themeMode.name,
onThemeChange = { modeName ->
try {
val newMode = ThemeMode.valueOf(modeName)
themePreferenceManager.setThemeMode(newMode)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Invalid theme mode: $modeName", e)
scope.launch {
snackbarHostState.showSnackbar("Invalid theme mode: $modeName")
}
}
Comment on lines +163 to +171
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling here logs the error and shows a snackbar, which is good. However, the error message "Invalid theme mode: $modeName" would only occur if there's a programming error (mismatch between the UI theme mode strings and the ThemeMode enum values). Since the ThemeSelector component uses the same string values as the enum names ("LIGHT", "DARK", "SYSTEM"), this error should never occur in normal operation. Consider adding an assertion or removing this error handling since it represents an internal inconsistency that should be caught during development rather than at runtime.

Suggested change
try {
val newMode = ThemeMode.valueOf(modeName)
themePreferenceManager.setThemeMode(newMode)
} catch (e: IllegalArgumentException) {
Log.e(TAG, "Invalid theme mode: $modeName", e)
scope.launch {
snackbarHostState.showSnackbar("Invalid theme mode: $modeName")
}
}
val newMode = ThemeMode.valueOf(modeName)
themePreferenceManager.setThemeMode(newMode)

Copilot uses AI. Check for mistakes.
},
onTestWebViewClick = {
// Launch test WebView activity
startActivity(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package llc.lookatwhataicando.codeoba.android

import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

/**
* Theme mode options for the app.
*/
enum class ThemeMode {
LIGHT,
DARK,
SYSTEM
}

/**
* Manages theme preferences for the application.
* Stores user's theme selection and provides reactive state updates.
*/
class ThemePreferenceManager(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
"theme_prefs",
Context.MODE_PRIVATE
)

private val _themeMode = MutableStateFlow(loadThemeMode())
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()

companion object {
private const val KEY_THEME_MODE = "theme_mode"
}

private fun loadThemeMode(): ThemeMode {
val savedMode = prefs.getString(KEY_THEME_MODE, ThemeMode.SYSTEM.name)
return try {
ThemeMode.valueOf(savedMode ?: ThemeMode.SYSTEM.name)
} catch (e: IllegalArgumentException) {
ThemeMode.SYSTEM
}
}

fun setThemeMode(mode: ThemeMode) {
prefs.edit().putString(KEY_THEME_MODE, mode.name).apply()
_themeMode.value = mode
}
}

/**
* Composable helper to get the current theme mode.
*/
@Composable
fun ThemePreferenceManager.currentThemeMode(): ThemeMode {
val mode by themeMode.collectAsState()
return mode
}
17 changes: 17 additions & 0 deletions app-android/src/main/res/values-night/themes.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark theme using Material3 with dynamic colors -->
<style name="Theme.Codeoba" parent="android:Theme.Material.NoActionBar">
<!-- Enable edge-to-edge display -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<!-- Status bar configuration for dark mode -->
<item name="android:statusBarColor">@android:color/transparent</item>
<!-- Use light icons (white) on dark background -->
<item name="android:windowLightStatusBar">false</item>
<!-- Navigation bar configuration -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightNavigationBar">false</item>
<!-- Add status bar scrim for better contrast -->
<item name="android:enforceStatusBarContrast">true</item>
</style>
</resources>
13 changes: 11 additions & 2 deletions app-android/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme using Material3 -->
<!-- Base application theme using Material3 with dynamic colors -->
<style name="Theme.Codeoba" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/ic_launcher_background</item>
<!-- Enable edge-to-edge display -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<!-- Status bar configuration for light mode -->
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>
<!-- Navigation bar configuration -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightNavigationBar">true</item>
<!-- Add status bar scrim for better contrast -->
<item name="android:enforceStatusBarContrast">true</item>
Comment on lines +5 to +14
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The android:enforceStatusBarContrast attribute was added in API 29, but the app's minSdk is set to 30 in the version catalog, so this is compatible. However, the android:windowLayoutInDisplayCutoutMode attribute with value "shortEdges" was added in API 28, and android:windowLightNavigationBar was added in API 27. Since minSdk is 30, all these attributes are safe to use. Consider adding an XML comment noting the minimum API requirements for these attributes to help future maintainers understand the API level dependencies.

Copilot uses AI. Check for mistakes.
</style>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
Expand All @@ -34,13 +36,17 @@ import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import androidx.compose.material3.TopAppBarDefaults is unused and should be removed. While the windowInsets parameter was added to TopAppBar on line 175, it doesn't require TopAppBarDefaults to be imported.

Suggested change
import androidx.compose.material3.TopAppBarDefaults

Copilot uses AI. Check for mistakes.
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
Expand Down Expand Up @@ -71,6 +77,8 @@ import llc.lookatwhataicando.codeoba.core.mirror
fun CodeobaUI(
app: CodeobaApp,
config: RealtimeConfig,
currentThemeMode: String = "SYSTEM",
onThemeChange: ((String) -> Unit)? = null,
onTestWebViewClick: (() -> Unit)? = null
) {
val connectionState by app.connectionState.collectAsState()
Expand All @@ -90,7 +98,6 @@ fun CodeobaUI(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
// Placeholder drawer content
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
Expand All @@ -102,6 +109,20 @@ fun CodeobaUI(
)
HorizontalDivider()

// Theme Selector
if (onThemeChange != null) {
Text(
"Theme",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
ThemeSelector(
currentMode = currentThemeMode,
onModeSelected = onThemeChange
)
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
}

// Test WebView menu item
if (onTestWebViewClick != null) {
TextButton(
Expand Down Expand Up @@ -150,7 +171,8 @@ fun CodeobaUI(
enabled = connectionState !is ConnectionState.Connecting
)
}
}
},
windowInsets = WindowInsets(0, 0, 0, 0)
)

// Tabs below the top bar
Expand Down Expand Up @@ -617,3 +639,39 @@ fun AudioRoutePanel(
}
}

@Composable
fun ThemeSelector(
currentMode: String,
onModeSelected: (String) -> Unit
) {
val themeModes = listOf("LIGHT", "DARK", "SYSTEM")
val themeLabels = mapOf(
"LIGHT" to "☀️ Light",
"DARK" to "🌙 Dark",
"SYSTEM" to "⚙️ System"
)

val selectedIndex = themeModes.indexOf(currentMode).takeIf { it >= 0 } ?: 2

SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
themeModes.forEachIndexed { index, mode ->
SegmentedButton(
selected = index == selectedIndex,
onClick = { onModeSelected(mode) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = themeModes.size
),
icon = {}
Comment on lines +666 to +667
Copy link

Copilot AI Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The icon parameter is set to an empty lambda {}, which creates an empty composable slot. According to Material 3 SegmentedButton best practices, if you don't want to display an icon, you should omit the parameter entirely rather than passing an empty lambda. This can cause unnecessary recomposition overhead and doesn't follow the intended API design.

Suggested change
),
icon = {}
)

Copilot uses AI. Check for mistakes.
) {
Text(
text = themeLabels[mode] ?: mode,
style = MaterialTheme.typography.labelLarge
)
}
}
}
}

Loading