diff --git a/app-android/build.gradle.kts b/app-android/build.gradle.kts index a1fa858..2f0394c 100644 --- a/app-android/build.gradle.kts +++ b/app-android/build.gradle.kts @@ -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) } } @@ -38,8 +38,8 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_25 - targetCompatibility = JavaVersion.VERSION_25 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } buildFeatures { diff --git a/app-android/src/main/kotlin/llc/lookatwhataicando/codeoba/android/MainActivity.kt b/app-android/src/main/kotlin/llc/lookatwhataicando/codeoba/android/MainActivity.kt index d5204bd..ee4d520 100644 --- a/app-android/src/main/kotlin/llc/lookatwhataicando/codeoba/android/MainActivity.kt +++ b/app-android/src/main/kotlin/llc/lookatwhataicando/codeoba/android/MainActivity.kt @@ -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 @@ -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" @@ -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() @@ -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 -> @@ -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") + } + } + }, onTestWebViewClick = { // Launch test WebView activity startActivity( diff --git a/app-android/src/main/kotlin/llc/lookatwhataicando/codeoba/android/ThemePreferenceManager.kt b/app-android/src/main/kotlin/llc/lookatwhataicando/codeoba/android/ThemePreferenceManager.kt new file mode 100644 index 0000000..f0c9e37 --- /dev/null +++ b/app-android/src/main/kotlin/llc/lookatwhataicando/codeoba/android/ThemePreferenceManager.kt @@ -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.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 +} diff --git a/app-android/src/main/res/values-night/themes.xml b/app-android/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..f8fd005 --- /dev/null +++ b/app-android/src/main/res/values-night/themes.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app-android/src/main/res/values/themes.xml b/app-android/src/main/res/values/themes.xml index 55593a7..572414b 100644 --- a/app-android/src/main/res/values/themes.xml +++ b/app-android/src/main/res/values/themes.xml @@ -1,7 +1,16 @@ - + diff --git a/core/src/commonMain/kotlin/llc/lookatwhataicando/codeoba/core/ui/CodeobaUI.kt b/core/src/commonMain/kotlin/llc/lookatwhataicando/codeoba/core/ui/CodeobaUI.kt index 17a6258..e528a27 100644 --- a/core/src/commonMain/kotlin/llc/lookatwhataicando/codeoba/core/ui/CodeobaUI.kt +++ b/core/src/commonMain/kotlin/llc/lookatwhataicando/codeoba/core/ui/CodeobaUI.kt @@ -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 @@ -34,6 +36,9 @@ 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 @@ -41,6 +46,7 @@ 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 import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -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() @@ -90,7 +98,6 @@ fun CodeobaUI( drawerState = drawerState, drawerContent = { ModalDrawerSheet { - // Placeholder drawer content Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) @@ -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( @@ -150,7 +171,8 @@ fun CodeobaUI( enabled = connectionState !is ConnectionState.Connecting ) } - } + }, + windowInsets = WindowInsets(0, 0, 0, 0) ) // Tabs below the top bar @@ -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 = {} + ) { + Text( + text = themeLabels[mode] ?: mode, + style = MaterialTheme.typography.labelLarge + ) + } + } + } +} +