diff --git a/panel-core/build.gradle.kts b/panel-core/build.gradle.kts index 8bbb4de4..601eba3f 100644 --- a/panel-core/build.gradle.kts +++ b/panel-core/build.gradle.kts @@ -3,6 +3,7 @@ plugins { id("convention.compose") id("convention-publish") id("convention.detekt") + alias(stack.plugins.kotlin.serialization) } description = "Debug panel core library" @@ -51,7 +52,9 @@ dependencies { implementation(androidx.appcompat) implementation(androidx.lifecycle.livedata) + implementation(androidx.datastore) implementation(stack.kotlinx.coroutines.android) + implementation(stack.kotlinx.serialization.json) implementation(stack.timber) implementation(stack.material) diff --git a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/DebugPanelInstance.kt b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/DebugPanelInstance.kt index a222e339..afa5a59d 100644 --- a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/DebugPanelInstance.kt +++ b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/DebugPanelInstance.kt @@ -4,9 +4,11 @@ import android.app.Application import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import com.redmadrobot.debug.core.data.storage.theme.ThemeDataStore import com.redmadrobot.debug.core.internal.CommonContainer import com.redmadrobot.debug.core.plugin.Plugin import com.redmadrobot.debug.core.plugin.PluginManager +import com.redmadrobot.debug.uikit.theme.model.ThemeMode import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -22,6 +24,9 @@ internal class DebugPanelInstance( extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) + private val themeDataStore by lazy { + ThemeDataStore(context = application.applicationContext) + } init { initContainer(application.applicationContext) @@ -46,6 +51,16 @@ internal class DebugPanelInstance( ?: error("PluginManager not initialised") } + internal fun observeDebugPanelTheme(): Flow { + return themeDataStore.observeThemeMode() + } + + internal suspend fun updateDebugPanelTheme(themeMode: ThemeMode) { + themeDataStore.saveThemeMode(mode = themeMode) + } + + internal fun getSelectedTheme() = themeDataStore.getSelectedTheme() + private fun initContainer(context: Context) { commonContainer = CommonContainer(context) } diff --git a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/model/ThemeData.kt b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/model/ThemeData.kt new file mode 100644 index 00000000..9d071273 --- /dev/null +++ b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/model/ThemeData.kt @@ -0,0 +1,9 @@ +package com.redmadrobot.debug.core.data.storage.model + +import com.redmadrobot.debug.uikit.theme.model.ThemeMode +import kotlinx.serialization.Serializable + +@Serializable +internal data class ThemeData( + val themeMode: ThemeMode = ThemeMode.System, +) diff --git a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/theme/ThemeDataSerializer.kt b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/theme/ThemeDataSerializer.kt new file mode 100644 index 00000000..4ad982ec --- /dev/null +++ b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/theme/ThemeDataSerializer.kt @@ -0,0 +1,23 @@ +package com.redmadrobot.debug.core.data.storage.theme + +import androidx.datastore.core.Serializer +import com.redmadrobot.debug.core.data.storage.model.ThemeData +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import java.io.InputStream +import java.io.OutputStream + +@OptIn(ExperimentalSerializationApi::class) +internal object ThemeDataSerializer : Serializer { + override val defaultValue: ThemeData = ThemeData() + + override suspend fun readFrom(input: InputStream): ThemeData { + return Json.decodeFromStream(input) + } + + override suspend fun writeTo(themeData: ThemeData, output: OutputStream) { + Json.encodeToStream(themeData, output) + } +} diff --git a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/theme/ThemeDataStore.kt b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/theme/ThemeDataStore.kt new file mode 100644 index 00000000..96937231 --- /dev/null +++ b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/theme/ThemeDataStore.kt @@ -0,0 +1,24 @@ +package com.redmadrobot.debug.core.data.storage.theme + +import android.content.Context +import com.redmadrobot.debug.uikit.theme.model.ThemeMode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking + +internal class ThemeDataStore(private val context: Context) { + private val dataStore by lazy { context.themeStorage } + + fun getSelectedTheme(): ThemeMode { + return runBlocking { dataStore.data.first().themeMode } + } + + fun observeThemeMode(): Flow { + return dataStore.data.map { it.themeMode } + } + + suspend fun saveThemeMode(mode: ThemeMode) { + dataStore.updateData { data -> data.copy(themeMode = mode) } + } +} diff --git a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/theme/ThemeStorage.kt b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/theme/ThemeStorage.kt new file mode 100644 index 00000000..2d3fe1c8 --- /dev/null +++ b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/data/storage/theme/ThemeStorage.kt @@ -0,0 +1,9 @@ +package com.redmadrobot.debug.core.data.storage.theme + +import android.content.Context +import androidx.datastore.dataStore + +internal val Context.themeStorage by dataStore( + fileName = "debug_panel_theme.json", + serializer = ThemeDataSerializer, +) diff --git a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/inapp/compose/DebugPanelScreen.kt b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/inapp/compose/DebugPanelScreen.kt index 571ed882..010b8128 100644 --- a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/inapp/compose/DebugPanelScreen.kt +++ b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/inapp/compose/DebugPanelScreen.kt @@ -28,10 +28,16 @@ import androidx.compose.ui.unit.dp import com.redmadrobot.debug.core.R import com.redmadrobot.debug.core.extension.getAllPlugins import com.redmadrobot.debug.core.plugin.Plugin +import com.redmadrobot.debug.uikit.theme.model.ThemeMode import kotlinx.coroutines.launch +@Suppress("UnusedParameter") @Composable -public fun DebugPanelScreen(onClose: () -> Unit) { +public fun DebugPanelScreen( + themeMode: ThemeMode, + onClose: () -> Unit, + onThemeModeChange: (ThemeMode) -> Unit = {} +) { val plugins = remember { getAllPlugins() } val pluginsName = remember { plugins.map { it.getName() } } val pagerState = rememberPagerState(initialPage = 0, pageCount = { plugins.size }) diff --git a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/debugpanel/DebugPanelActivity.kt b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/debugpanel/DebugPanelActivity.kt index 66e35ffa..713d59ed 100644 --- a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/debugpanel/DebugPanelActivity.kt +++ b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/debugpanel/DebugPanelActivity.kt @@ -3,18 +3,32 @@ package com.redmadrobot.debug.core.ui.debugpanel import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.redmadrobot.debug.core.DebugPanel import com.redmadrobot.debug.core.inapp.compose.DebugPanelScreen import com.redmadrobot.debug.uikit.theme.DebugPanelTheme -import com.redmadrobot.debug.uikit.theme.ThemeState +import kotlinx.coroutines.launch internal class DebugPanelActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setContent { - val themeState = remember { ThemeState() } - DebugPanelTheme(themeState = themeState) { - DebugPanelScreen(onClose = { finish() }) + val debugPanel = remember { DebugPanel.getInstance()!! } + val themeMode by debugPanel.observeDebugPanelTheme() + .collectAsStateWithLifecycle(initialValue = debugPanel.getSelectedTheme()) + + DebugPanelTheme(themeMode = themeMode) { + DebugPanelScreen( + themeMode = themeMode, + onThemeModeChange = { mode -> + lifecycleScope.launch { debugPanel.updateDebugPanelTheme(themeMode = mode) } + }, + onClose = { finish() }, + ) } } } diff --git a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/settings/DebugSettingsActivity.kt b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/settings/DebugSettingsActivity.kt index 7988afd9..fc3c8c4f 100644 --- a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/settings/DebugSettingsActivity.kt +++ b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/settings/DebugSettingsActivity.kt @@ -3,21 +3,31 @@ package com.redmadrobot.debug.core.ui.settings import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.rememberNavController +import com.redmadrobot.debug.core.DebugPanel import com.redmadrobot.debug.core.extension.getAllPlugins import com.redmadrobot.debug.core.internal.EditablePlugin +import com.redmadrobot.debug.uikit.theme.DebugPanelTheme internal class DebugSettingsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - MaterialTheme { + val debugPanel = remember { DebugPanel.getInstance()!! } + val themeMode by debugPanel.observeDebugPanelTheme() + .collectAsStateWithLifecycle(initialValue = debugPanel.getSelectedTheme()) + + DebugPanelTheme(themeMode = themeMode) { val navController = rememberNavController() val pluginItems = remember { getSettingItems() } - DebugSettingsNavHost(navController = navController, pluginItems = pluginItems) + DebugSettingsNavHost( + navController = navController, + pluginItems = pluginItems, + ) } } } diff --git a/panel-ui-kit/build.gradle.kts b/panel-ui-kit/build.gradle.kts index 25c65ef8..a12e41b1 100644 --- a/panel-ui-kit/build.gradle.kts +++ b/panel-ui-kit/build.gradle.kts @@ -42,4 +42,5 @@ dependencies { implementation(androidx.compose.material3) implementation(androidx.compose.ui.tooling) implementation(androidx.compose.ui.tooling.preview) + implementation(androidx.core) } diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/components/ThemeSwitcher.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/components/ThemeSwitcher.kt new file mode 100644 index 00000000..e396ac27 --- /dev/null +++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/components/ThemeSwitcher.kt @@ -0,0 +1,65 @@ +package com.redmadrobot.debug.uikit.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.redmadrobot.debug.uikit.theme.DebugPanelTheme +import com.redmadrobot.debug.uikit.theme.model.ThemeMode + +@Composable +public fun ThemeSwitcher( + currentMode: ThemeMode, + onModeSelect: (ThemeMode) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + + IconButton(onClick = { expanded = true }, modifier = modifier) { + Icon( + painter = painterResource(id = ThemeMode.getIconRes(mode = currentMode)), + contentDescription = null, + tint = DebugPanelTheme.colors.content.secondary, + modifier = Modifier.size(size = 20.dp), + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + ThemeMode.entries.forEach { mode -> + key(mode.name) { + DropdownMenuItem( + title = stringResource(id = ThemeMode.getTitleRes(mode = mode)), + onModeSelect = { + onModeSelect.invoke(mode) + expanded = false + } + ) + } + } + } + } +} + +@Composable +private fun DropdownMenuItem( + title: String, + onModeSelect: () -> Unit +) { + DropdownMenuItem( + text = { Text(text = title, style = DebugPanelTheme.typography.bodyMedium) }, + onClick = { onModeSelect() }, + ) +} diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/BaseColors.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/BaseColors.kt index 3baf438b..5f6af085 100644 --- a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/BaseColors.kt +++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/BaseColors.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.redmadrobot.debug.uikit.theme.model.ThemeMode internal object BaseColors { // Purple @@ -92,7 +93,7 @@ internal object BaseColors { @Composable @Preview(showBackground = true) private fun Preview() { - DebugPanelTheme { + DebugPanelTheme(themeMode = ThemeMode.Light) { FlowRow( modifier = Modifier .fillMaxSize() diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelColorPresets.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelColorPresets.kt index c3d10e98..29528aa0 100644 --- a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelColorPresets.kt +++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelColorPresets.kt @@ -1,5 +1,8 @@ package com.redmadrobot.debug.uikit.theme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.lightColorScheme + internal val LightDebugPanelColors = DebugPanelColors() internal val DarkDebugPanelColors = DebugPanelColors( @@ -44,3 +47,33 @@ internal val DarkDebugPanelColors = DebugPanelColors( remoteBackground = BaseColors.OrangeDark, ), ) + +internal fun DebugPanelColors.toMaterialColorScheme(): ColorScheme = lightColorScheme( + primary = button.primary, + onPrimary = button.onPrimary, + primaryContainer = surface.tertiary, + onPrimaryContainer = content.primary, + secondary = content.secondary, + onSecondary = button.onPrimary, + secondaryContainer = button.secondary, + onSecondaryContainer = content.primary, + tertiary = content.tertiary, + onTertiary = button.onPrimary, + error = content.error, + onError = button.onError, + errorContainer = BaseColors.Error90, + onErrorContainer = BaseColors.Error20, + background = background.primary, + onBackground = content.primary, + surface = surface.primary, + onSurface = content.primary, + surfaceVariant = stroke.primary, + onSurfaceVariant = content.secondary, + outline = content.tertiary, + outlineVariant = stroke.secondary, + surfaceContainer = surface.secondary, + surfaceContainerHigh = surface.tertiary, + inverseSurface = content.primary, + inverseOnSurface = background.primary, + inversePrimary = content.accent, +) diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelDimensions.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelDimensions.kt new file mode 100644 index 00000000..d1cf9c69 --- /dev/null +++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelDimensions.kt @@ -0,0 +1,22 @@ +package com.redmadrobot.debug.uikit.theme + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +public object DebugPanelDimensions { + // Common + public val topBarHeight: Dp = 56.dp + public val tabRowHeight: Dp = 48.dp + public val bottomBarHeight: Dp = 56.dp + public val rowMinHeight: Dp = 48.dp + + // Toggles + public val toggleWidth: Dp = 44.dp + public val toggleHeight: Dp = 24.dp + public val dotSize: Dp = 10.dp + + // Icons + public val iconSizeSmall: Dp = 20.dp + public val iconSizeMedium: Dp = 24.dp + public val iconSizeLarge: Dp = 32.dp +} diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelShapes.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelShapes.kt new file mode 100644 index 00000000..4c7269ce --- /dev/null +++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelShapes.kt @@ -0,0 +1,11 @@ +package com.redmadrobot.debug.uikit.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp + +public object DebugPanelShapes { + public val small: RoundedCornerShape = RoundedCornerShape(4.dp) + public val medium: RoundedCornerShape = RoundedCornerShape(8.dp) + public val large: RoundedCornerShape = RoundedCornerShape(20.dp) + public val dialog: RoundedCornerShape = RoundedCornerShape(28.dp) +} diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/SystemBarsColors.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/SystemBarsColors.kt new file mode 100644 index 00000000..ce133542 --- /dev/null +++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/SystemBarsColors.kt @@ -0,0 +1,31 @@ +package com.redmadrobot.debug.uikit.theme + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color + +/** + * Colors applied to the system status bar and navigation bar. + * + * By default, both bars use the primary background color of the current theme. + * Light/dark icon appearance is determined automatically based on color luminance. + * + * Consumers can override the mapping by providing a custom [SystemBarsColors] instance + * or a `systemBarsColors` lambda to [DebugPanelTheme]. + */ +@Stable +public data class SystemBarsColors( + /** Color of the system status bar. */ + val statusBarColor: Color, + /** Color of the system navigation bar. */ + val navigationBarColor: Color, +) { + public companion object { + /** + * Default mapping: both bars use [DebugPanelColors.background] primary. + */ + public fun fromTheme(colors: DebugPanelColors): SystemBarsColors = SystemBarsColors( + statusBarColor = colors.background.primary, + navigationBarColor = colors.background.primary, + ) + } +} diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/SystemBarsEffect.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/SystemBarsEffect.kt new file mode 100644 index 00000000..3aac838e --- /dev/null +++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/SystemBarsEffect.kt @@ -0,0 +1,87 @@ +package com.redmadrobot.debug.uikit.theme + +import android.app.Activity +import android.view.View +import android.view.Window +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.WindowCompat + +/** + * Applies [systemBarsColors] to the host window on every recomposition. + * + * Works both inside an Activity (`setContent { }`) and inside popup windows + * such as `ModalBottomSheet` (detected via [DialogWindowProvider]). + * + * - Uses [WindowInsetsControllerCompat][androidx.core.view.WindowInsetsControllerCompat] + * for icon tinting across all supported API levels. + * - Sets `window.statusBarColor` / `window.navigationBarColor` for bar backgrounds + * (effective on API 23-34; no-op on API 35+ where edge-to-edge is enforced). + * - Light/dark icon style is chosen automatically via color luminance: + * bright background → dark icons (`isAppearanceLightXxxBars = true`). + * + * If the current [LocalView] is not attached to an Activity or dialog window, + * the effect is silently skipped. + */ +@Composable +public fun SystemBarsEffect(systemBarsColors: SystemBarsColors) { + val view = LocalView.current + if (view.isInEditMode) return + + val statusArgb = systemBarsColors.statusBarColor.toArgb() + val navArgb = systemBarsColors.navigationBarColor.toArgb() + val lightStatus = systemBarsColors.statusBarColor.isLight() + val lightNav = systemBarsColors.navigationBarColor.isLight() + + SideEffect { + val window = findWindow(view) ?: return@SideEffect + + @Suppress("DEPRECATION") + window.statusBarColor = statusArgb + @Suppress("DEPRECATION") + window.navigationBarColor = navArgb + + val controller = WindowCompat.getInsetsController(window, view) + controller.isAppearanceLightStatusBars = lightStatus + controller.isAppearanceLightNavigationBars = lightNav + } +} + +/** BT.709 coefficient for red channel. */ +private const val LUMINANCE_RED = 0.2126f + +/** BT.709 coefficient for green channel. */ +private const val LUMINANCE_GREEN = 0.7152f + +/** BT.709 coefficient for blue channel. */ +private const val LUMINANCE_BLUE = 0.0722f + +/** Luminance threshold above which the color is considered "light". */ +private const val LUMINANCE_THRESHOLD = 0.5f + +/** + * Returns `true` when the color's relative luminance is high enough + * that dark (black) icons should be drawn on top of it. + * + * Uses the BT.709 coefficients for relative luminance. Works on all API levels + * (no dependency on [android.graphics.Color.luminance] which requires API 26). + */ +private fun Color.isLight(): Boolean { + val luminance = LUMINANCE_RED * red + LUMINANCE_GREEN * green + LUMINANCE_BLUE * blue + return luminance > LUMINANCE_THRESHOLD +} + +/** + * Resolves the [Window] for the current view. + * + * Checks [DialogWindowProvider] first (covers `ModalBottomSheet` and other popup windows), + * then falls back to the host Activity's window. + */ +private fun findWindow(view: View): Window? { + return (view.parent as? DialogWindowProvider)?.window + ?: (view.context as? Activity)?.window +} diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Theme.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Theme.kt index 47cde525..8a43f784 100644 --- a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Theme.kt +++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Theme.kt @@ -6,44 +6,15 @@ import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.runtime.Stable import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext - -/** - * Theme mode selectable at runtime from debug panel settings. - */ -public enum class ThemeMode { - /** Follow system dark/light setting. */ - System, - - /** Always light. */ - Light, - - /** Always dark. */ - Dark, -} - -/** - * Holds the currently selected [ThemeMode]. - * - * Mutate [themeMode] from settings UI — the composable tree will recompose automatically. - */ -@Stable -public class ThemeState(initialMode: ThemeMode = ThemeMode.System) { - public var themeMode: ThemeMode by mutableStateOf(initialMode) -} +import com.redmadrobot.debug.uikit.theme.model.ThemeMode internal val LocalColors = compositionLocalOf { LightDebugPanelColors } internal val LocalTypography = compositionLocalOf { DebugPanelTypographyTokens() } -internal val LocalThemeState = compositionLocalOf { ThemeState() } /** * Entry point for accessing Debug Panel design tokens. @@ -52,7 +23,6 @@ internal val LocalThemeState = compositionLocalOf { ThemeState() } * ``` * color = DebugPanelTheme.colors.background.primary * style = DebugPanelTheme.typography.bodySmall - * mode = DebugPanelTheme.themeState.themeMode * ``` */ public object DebugPanelTheme { @@ -65,11 +35,6 @@ public object DebugPanelTheme { @Composable @ReadOnlyComposable get() = LocalTypography.current - - public val themeState: ThemeState - @Composable - @ReadOnlyComposable - get() = LocalThemeState.current } /** @@ -78,42 +43,47 @@ public object DebugPanelTheme { * Provides [DebugPanelColors] and [DebugPanelTypographyTokens] via [CompositionLocal], * and configures Material 3 [ColorScheme] for standard M3 components. * - * When [themeState]`.themeMode` changes at runtime (e.g. from settings), + * When [themeMode] changes at runtime (e.g. from settings), * the entire UI recomposes with animated color transitions. * - * @param themeState holds the runtime-mutable [ThemeMode]. Shared across the panel so that - * settings can write to it and all screens react. + * @param themeMode the current [ThemeMode] selection. * @param dynamicColor whether to use Material You dynamic colors on supported devices. + * @param systemBarsColors optional lambda that maps the resolved [DebugPanelColors] to + * [SystemBarsColors]. Pass `null` to skip system bar coloring entirely. Defaults to + * [SystemBarsColors.fromTheme] which uses [BackgroundColors.primary] for both bars. * @param content the composable content to be themed. */ @Composable public fun DebugPanelTheme( - themeState: ThemeState = ThemeState(), + themeMode: ThemeMode, dynamicColor: Boolean = true, + systemBarsColors: ((DebugPanelColors) -> SystemBarsColors)? = SystemBarsColors::fromTheme, content: @Composable () -> Unit, ) { - val systemDark = isSystemInDarkTheme() - val darkTheme = when (themeState.themeMode) { - ThemeMode.System -> systemDark + val isDarkTheme = when (themeMode) { + ThemeMode.System -> isSystemInDarkTheme() ThemeMode.Light -> false ThemeMode.Dark -> true } - val targetColors = if (darkTheme) DarkDebugPanelColors else LightDebugPanelColors + val targetColors = if (isDarkTheme) DarkDebugPanelColors else LightDebugPanelColors val panelColors = targetColors.animated() - val panelTypography = if (darkTheme) DarkDebugPanelTypographyTokens else DebugPanelTypographyTokens() + val panelTypography = if (isDarkTheme) DarkDebugPanelTypographyTokens else DebugPanelTypographyTokens() val materialColorScheme = if (dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } else { panelColors.toMaterialColorScheme() } + systemBarsColors?.let { colors -> + SystemBarsEffect(systemBarsColors = colors.invoke(panelColors)) + } + CompositionLocalProvider( LocalColors provides panelColors, LocalTypography provides panelTypography, - LocalThemeState provides themeState, ) { MaterialTheme( colorScheme = materialColorScheme, @@ -122,33 +92,3 @@ public fun DebugPanelTheme( ) } } - -internal fun DebugPanelColors.toMaterialColorScheme(): ColorScheme = lightColorScheme( - primary = button.primary, - onPrimary = button.onPrimary, - primaryContainer = surface.tertiary, - onPrimaryContainer = content.primary, - secondary = content.secondary, - onSecondary = button.onPrimary, - secondaryContainer = button.secondary, - onSecondaryContainer = content.primary, - tertiary = content.tertiary, - onTertiary = button.onPrimary, - error = content.error, - onError = button.onError, - errorContainer = BaseColors.Error90, - onErrorContainer = BaseColors.Error20, - background = background.primary, - onBackground = content.primary, - surface = surface.primary, - onSurface = content.primary, - surfaceVariant = stroke.primary, - onSurfaceVariant = content.secondary, - outline = content.tertiary, - outlineVariant = stroke.secondary, - surfaceContainer = surface.secondary, - surfaceContainerHigh = surface.tertiary, - inverseSurface = content.primary, - inverseOnSurface = background.primary, - inversePrimary = content.accent, -) diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Typography.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Typography.kt index 2b574250..4cb5c231 100644 --- a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Typography.kt +++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Typography.kt @@ -15,65 +15,65 @@ public data class DebugPanelTypographyTokens( /** TopAppBar title. */ val titleLarge: TextStyle = TextStyle( fontWeight = FontWeight.Medium, - fontSize = 16.sp, - lineHeight = 24.sp, + fontSize = 20.sp, + lineHeight = 28.sp, letterSpacing = 0.sp, ), /** Section headers, group names. */ val titleMedium: TextStyle = TextStyle( fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, ), /** Card titles, server names. */ val titleSmall: TextStyle = TextStyle( fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 20.sp, + fontSize = 15.sp, + lineHeight = 22.sp, letterSpacing = 0.1.sp, ), /** Primary body text. */ val bodyLarge: TextStyle = TextStyle( fontWeight = FontWeight.Normal, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.25.sp, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, ), /** Secondary body text, config values. */ val bodyMedium: TextStyle = TextStyle( fontWeight = FontWeight.Normal, - fontSize = 13.sp, - lineHeight = 18.sp, + fontSize = 14.sp, + lineHeight = 20.sp, letterSpacing = 0.25.sp, ), - /** Technical values (URLs, keys) — compact monospace. */ + /** Technical values (URLs, keys) — monospace. */ val bodySmall: TextStyle = TextStyle( fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 16.sp, + fontSize = 13.sp, + lineHeight = 18.sp, letterSpacing = 0.4.sp, fontFamily = MonoFontFamily, ), - /** Tab labels, chip text. */ + /** Tab labels, chip text, button labels. */ val labelLarge: TextStyle = TextStyle( fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 16.sp, + fontSize = 14.sp, + lineHeight = 20.sp, letterSpacing = 0.1.sp, ), /** Badges, source indicators. */ val labelMedium: TextStyle = TextStyle( fontWeight = FontWeight.Medium, - fontSize = 11.sp, + fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp, ), /** Section headers (uppercase), overline text. */ val labelSmall: TextStyle = TextStyle( fontWeight = FontWeight.Medium, - fontSize = 10.sp, - lineHeight = 14.sp, + fontSize = 11.sp, + lineHeight = 16.sp, letterSpacing = 1.sp, ), ) @@ -81,8 +81,8 @@ public data class DebugPanelTypographyTokens( internal val DarkDebugPanelTypographyTokens = DebugPanelTypographyTokens( bodySmall = TextStyle( fontWeight = FontWeight.Normal, - fontSize = 12.sp, - lineHeight = 16.sp, + fontSize = 13.sp, + lineHeight = 18.sp, letterSpacing = 0.4.sp, fontFamily = MonoFontFamily, color = BaseColors.Neutral80, diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/model/ThemeMode.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/model/ThemeMode.kt new file mode 100644 index 00000000..18022cfa --- /dev/null +++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/model/ThemeMode.kt @@ -0,0 +1,35 @@ +package com.redmadrobot.debug.uikit.theme.model + +import com.redmadrobot.debug.uikit.R + +/** + * Theme mode selectable at runtime from debug panel settings. + */ +public enum class ThemeMode { + /** Follow system dark/light setting. */ + System, + + /** Always light. */ + Light, + + /** Always dark. */ + Dark; + + public companion object { + public fun getTitleRes(mode: ThemeMode): Int { + return when (mode) { + System -> R.string.debug_panel_theme_system + Light -> R.string.debug_panel_theme_light + Dark -> R.string.debug_panel_theme_dark + } + } + + public fun getIconRes(mode: ThemeMode): Int { + return when (mode) { + System -> R.drawable.icon_brightness_auto + Light -> R.drawable.icon_light_mode + Dark -> R.drawable.icon_dark_mode + } + } + } +} diff --git a/panel-ui-kit/src/main/res/drawable/icon_brightness_auto.xml b/panel-ui-kit/src/main/res/drawable/icon_brightness_auto.xml new file mode 100644 index 00000000..08b21855 --- /dev/null +++ b/panel-ui-kit/src/main/res/drawable/icon_brightness_auto.xml @@ -0,0 +1,10 @@ + + + diff --git a/panel-ui-kit/src/main/res/drawable/icon_dark_mode.xml b/panel-ui-kit/src/main/res/drawable/icon_dark_mode.xml new file mode 100644 index 00000000..683d6b69 --- /dev/null +++ b/panel-ui-kit/src/main/res/drawable/icon_dark_mode.xml @@ -0,0 +1,10 @@ + + + diff --git a/panel-ui-kit/src/main/res/drawable/icon_light_mode.xml b/panel-ui-kit/src/main/res/drawable/icon_light_mode.xml new file mode 100644 index 00000000..537276ad --- /dev/null +++ b/panel-ui-kit/src/main/res/drawable/icon_light_mode.xml @@ -0,0 +1,10 @@ + + + diff --git a/panel-ui-kit/src/main/res/values/strings.xml b/panel-ui-kit/src/main/res/values/strings.xml new file mode 100644 index 00000000..40782789 --- /dev/null +++ b/panel-ui-kit/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + System + Light + Dark +