docs/architecture/theme-system.md
This document provides a detailed explanation of the theming system used in our applications. It covers the theme architecture, components, customization, and usage.
Our theme architecture is designed with several key principles in mind:
The theming system follows a hierarchical structure:
graph TD
subgraph APP_THEMES["App-Specific Themes"]
TB_THEME[ThunderbirdTheme2]
K9_THEME[K9MailTheme2]
end
subgraph MAIN["Main Theme"]
MAIN_THEME[MainTheme]
THEME_CONFIG[ThemeConfig]
end
subgraph MATERIAL["Material Design 3"]
MAT_THEME[MaterialTheme]
end
TB_THEME --> |uses| MAIN_THEME
TB_THEME --> |defines| THEME_CONFIG
K9_THEME --> |uses| MAIN_THEME
K9_THEME --> |defines| THEME_CONFIG
THEME_CONFIG --> |configures| MAIN_THEME
MAIN_THEME --> |wraps| MAT_THEME
classDef app_theme fill:#d9ffd9,stroke:#000000,color:#000000
classDef main_theme fill:#d9e9ff,stroke:#000000,color:#000000
classDef material fill:#ffe6cc,stroke:#000000,color:#000000
linkStyle default stroke:#999,stroke-width:2px
class TB_THEME,K9_THEME app_theme
class MAIN_THEME,THEME_CONFIG main_theme
class MAT_THEME material
The theme system consists of three main layers:
The theme data flows through the system as follows:
This architecture provides several benefits:
The theming system consists of several components that work together to provide a comprehensive and consistent visual experience across the application. Each component is responsible for a specific aspect of the UI design.
The ThemeConfig is the central configuration class that holds all theme components. It serves as a container for all theme-related settings and is passed to the MainTheme composable.
data class ThemeConfig(
val colors: ThemeColorSchemeVariants,
val elevations: ThemeElevations,
val images: ThemeImageVariants,
val shapes: ThemeShapes,
val sizes: ThemeSizes,
val spacings: ThemeSpacings,
val typography: ThemeTypography,
)
The ThemeConfig allows for:
The ThemeColorScheme defines all colors used in the application. It extends Material Design 3's color system with additional colors specific to our applications.
data class ThemeColorScheme(
// Material 3 colors
val primary: Color,
val onPrimary: Color,
val primaryContainer: Color,
val onPrimaryContainer: Color,
val secondary: Color,
val onSecondary: Color,
val secondaryContainer: Color,
val onSecondaryContainer: Color,
val tertiary: Color,
val onTertiary: Color,
val tertiaryContainer: Color,
val onTertiaryContainer: Color,
val error: Color,
val onError: Color,
val errorContainer: Color,
val onErrorContainer: Color,
val surfaceDim: Color,
val surface: Color,
val surfaceBright: Color,
val onSurface: Color,
val onSurfaceVariant: Color,
val surfaceContainerLowest: Color,
val surfaceContainerLow: Color,
val surfaceContainer: Color,
val surfaceContainerHigh: Color,
val surfaceContainerHighest: Color,
val inverseSurface: Color,
val inverseOnSurface: Color,
val inversePrimary: Color,
val outline: Color,
val outlineVariant: Color,
val scrim: Color,
// Extra colors
val info: Color,
val onInfo: Color,
val infoContainer: Color,
val onInfoContainer: Color,
val success: Color,
val onSuccess: Color,
val successContainer: Color,
val onSuccessContainer: Color,
val warning: Color,
val onWarning: Color,
val warningContainer: Color,
val onWarningContainer: Color,
)
The color scheme is organized into:
Colors are provided in variants for both light and dark themes through the ThemeColorSchemeVariants class:
data class ThemeColorSchemeVariants(
val light: ThemeColorScheme,
val dark: ThemeColorScheme,
)
The ThemeElevations component defines standard elevation values used throughout the application to create a consistent sense of depth and hierarchy.
data class ThemeElevations(
val level0: Dp,
val level1: Dp,
val level2: Dp,
val level3: Dp,
val level4: Dp,
val level5: Dp,
)
Typical usage includes:
The ThemeImages component stores references to app-specific images like logos, icons, and illustrations.
data class ThemeImages(
val logo: Int, // Resource ID
// ... other image resources
)
These images can have light and dark variants through the ThemeImageVariants class:
data class ThemeImageVariants(
val light: ThemeImages,
val dark: ThemeImages,
)
The ThemeShapes component defines the corner shapes used for UI elements throughout the application.
data class ThemeShapes(
val extraSmall: CornerBasedShape,
val small: CornerBasedShape,
val medium: CornerBasedShape,
val large: CornerBasedShape,
val extraLarge: CornerBasedShape,
)
These shapes are used for:
Note: For no rounding (0% corner radius), use RectangleShape. For completely rounded corners (50% corner radius) for circular elements, use CircleShape.
The ThemeShapes can be converted to Material 3 shapes using the toMaterial3Shapes() method for compatibility with Material components.
The ThemeSizes component defines standard size values for UI elements to ensure consistent sizing throughout the application.
data class ThemeSizes(
val smaller: Dp,
val small: Dp,
val medium: Dp,
val large: Dp,
val larger: Dp,
val huge: Dp,
val huger: Dp,
val iconSmall: Dp,
val icon: Dp,
val iconLarge: Dp,
val iconAvatar: Dp,
val topBarHeight: Dp,
val bottomBarHeight: Dp,
val bottomBarHeightWithFab: Dp,
)
These sizes are used for:
smaller, small, medium, large, larger, huge, huger for component dimensions (width, height), button heights, and other UI element dimensions that need standardizationiconSmall, icon, iconLarge for different icon sizes throughout the appiconAvatar for user avatars and profile picturestopBarHeight, bottomBarHeight, bottomBarHeightWithFab for consistent app bar and navigation bar heightsThe ThemeSpacings component defines standard spacing values used for margins, padding, and gaps between elements.
data class ThemeSpacings(
val zero: Dp,
val quarter: Dp,
val half: Dp,
val default: Dp,
val oneHalf: Dp,
val double: Dp,
val triple: Dp,
val quadruple: Dp,
)
Consistent spacing helps create a rhythmic and harmonious layout:
The ThemeTypography component defines text styles for different types of content throughout the application.
data class ThemeTypography(
// Display styles for large headlines
val displayLarge: TextStyle,
val displayMedium: TextStyle,
val displaySmall: TextStyle,
// Headline styles for section headers
val headlineLarge: TextStyle,
val headlineMedium: TextStyle,
val headlineSmall: TextStyle,
// Title styles for content titles
val titleLarge: TextStyle,
val titleMedium: TextStyle,
val titleSmall: TextStyle,
// Body styles for main content
val bodyLarge: TextStyle,
val bodyMedium: TextStyle,
val bodySmall: TextStyle,
// Label styles for buttons and small text
val labelLarge: TextStyle,
val labelMedium: TextStyle,
val labelSmall: TextStyle,
)
Each TextStyle includes:
The ThemeTypography can be converted to Material 3 typography using the toMaterial3Typography() method for compatibility with Material components.
These theme components work together to create a cohesive design system:
MainThemeCompositionLocal providersMainTheme objectThemeColorScheme and ThemeShapes are converted to Material 3 equivalents for use with Material componentsThis structured approach ensures consistent design application throughout the app while providing flexibility for customization.
The MainTheme is the foundation of our theming system:
MaterialThemeThemeConfig parameterMainTheme objectThe MainTheme function uses Jetpack Compose's CompositionLocalProvider to make theme components available throughout the composition tree:
@Composable
fun MainTheme(
themeConfig: ThemeConfig,
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val themeColorScheme = selectThemeColorScheme(
themeConfig = themeConfig,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
)
val themeImages = selectThemeImages(
themeConfig = themeConfig,
darkTheme = darkTheme,
)
SystemBar(
darkTheme = darkTheme,
colorScheme = themeColorScheme,
)
CompositionLocalProvider(
LocalThemeColorScheme provides themeColorScheme,
LocalThemeElevations provides themeConfig.elevations,
LocalThemeImages provides themeImages,
LocalThemeShapes provides themeConfig.shapes,
LocalThemeSizes provides themeConfig.sizes,
LocalThemeSpacings provides themeConfig.spacings,
LocalThemeTypography provides themeConfig.typography,
) {
MaterialTheme(
colorScheme = themeColorScheme.toMaterial3ColorScheme(),
shapes = themeConfig.shapes.toMaterial3Shapes(),
typography = themeConfig.typography.toMaterial3Typography(),
content = content,
)
}
}
Each theme component is provided through a CompositionLocal that makes it available to all composables in the composition tree. These CompositionLocal values are defined using staticCompositionLocalOf in their respective files:
internal val LocalThemeColorScheme = staticCompositionLocalOf<ThemeColorScheme> {
error("No ThemeColorScheme provided")
}
internal val LocalThemeElevations = staticCompositionLocalOf<ThemeElevations> {
error("No ThemeElevations provided")
}
// ... other LocalTheme* definitions
The MainTheme object provides properties to access these values from anywhere in the composition tree:
object MainTheme {
val colors: ThemeColorScheme
@Composable
@ReadOnlyComposable
get() = LocalThemeColorScheme.current
val elevations: ThemeElevations
@Composable
@ReadOnlyComposable
get() = LocalThemeElevations.current
// ... other properties
}
This theme provider mechanism ensures that theme components are available throughout the app without having to pass them as parameters to every composable.
The app-specific themes (ThunderbirdTheme2 and K9MailTheme2) customize the MainTheme for each application:
ThemeConfig@Composable
fun ThunderbirdTheme2(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val images = ThemeImages(
logo = R.drawable.core_ui_theme2_thunderbird_logo,
)
val themeConfig = ThemeConfig(
colors = ThemeColorSchemeVariants(
dark = darkThemeColorScheme,
light = lightThemeColorScheme,
),
elevations = defaultThemeElevations,
images = ThemeImageVariants(
light = images,
dark = images,
),
sizes = defaultThemeSizes,
spacings = defaultThemeSpacings,
shapes = defaultThemeShapes,
typography = defaultTypography,
)
MainTheme(
themeConfig = themeConfig,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
content = content,
)
}
@Composable
fun K9MailTheme2(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val images = ThemeImages(
logo = R.drawable.core_ui_theme2_k9mail_logo,
)
val themeConfig = ThemeConfig(
colors = ThemeColorSchemeVariants(
dark = darkThemeColorScheme,
light = lightThemeColorScheme,
),
elevations = defaultThemeElevations,
images = ThemeImageVariants(
light = images,
dark = images,
),
sizes = defaultThemeSizes,
spacings = defaultThemeSpacings,
shapes = defaultThemeShapes,
typography = defaultTypography,
)
MainTheme(
themeConfig = themeConfig,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
content = content,
)
}
To apply a theme to your UI, wrap your composables with the appropriate theme composable:
// For Thunderbird app
@Composable
fun ThunderbirdApp() {
ThunderbirdTheme2 {
// App content
}
}
// For K9Mail app
@Composable
fun K9MailApp() {
K9MailTheme2 {
// App content
}
}
Inside themed content, you can access theme properties through the MainTheme object:
@Composable
fun ThemedButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Button(
onClick = onClick,
modifier = modifier,
colors = ButtonDefaults.buttonColors(
containerColor = MainTheme.colors.primary,
contentColor = MainTheme.colors.onPrimary,
),
shape = MainTheme.shapes.medium,
) {
Text(
text = text,
style = MainTheme.typography.labelLarge,
)
}
}
The theming system supports both dark mode and dynamic color:
@Composable
fun ThunderbirdTheme2(
darkTheme: Boolean = isSystemInDarkTheme(), // Default to system setting
dynamicColor: Boolean = false, // Disabled by default
content: @Composable () -> Unit,
) {
// ...
}
To customize a theme, you can create a new theme composable that wraps MainTheme with your custom ThemeConfig:
@Composable
fun CustomTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val images = ThemeImages(
logo = R.drawable.custom_logo,
)
val themeConfig = ThemeConfig(
colors = ThemeColorSchemeVariants(
dark = customDarkThemeColorScheme,
light = customLightThemeColorScheme,
),
elevations = customThemeElevations,
images = ThemeImageVariants(
light = images,
dark = images,
),
sizes = customThemeSizes,
spacings = customThemeSpacings,
shapes = customThemeShapes,
typography = customTypography,
)
MainTheme(
themeConfig = themeConfig,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
content = content,
)
}
When writing tests for composables that use theme components, you need to wrap them in a theme:
@Test
fun testThemedButton() {
composeTestRule.setContent {
ThunderbirdTheme2 {
ThemedButton(
text = "Click Me",
onClick = {},
)
}
}
composeTestRule.onNodeWithText("Click Me").assertExists()
}