The PickMe Engineering Blog

At PickMe our purpose is to create joyful mobility experiences for our consumers. In this journey our technology leads the way and the solutions that we build help millions of customers and thousands of service partners to efficiently engage in a single platform.

Featured

Tenant-Specific Theming with Material 3 in Jetpack Compose

--

As mobile apps scale and serve diverse user bases, tenant-specific theming becomes increasingly important — especially for B2B platforms that support white-label branding. In a recent investigation, I explored how we can implement dynamic, theme-based color customization per tenant in our Android app using Jetpack Compose and Material 3.

The good news? It’s feasible, elegant, and aligns perfectly with the theming system provided by Material 3 in Compose.

Problem Statement

Our app needs to support multiple tenants, each with their own branding colors for light and dark modes. Rather than hardcoding specific colors like red or green, we want to define and use abstract semantic colors like primary, secondary, error, etc.

This way, the app remains consistent, scalable, and maintainable as tenant configurations evolve.

Proposed Solution

We define abstract color roles (primary, secondary, error, etc.) per tenant, for both light and dark themes. Once the UX team finalizes the colors, we’ll apply them dynamically at runtime using Jetpack Compose theming capabilities.

💡 Benefits

  • Clean UI Code: Abstracting color names decouples design from implementation.
  • Dynamic Brand Identity: Each tenant gets a unique look and feel.
  • Full Light & Dark Theme Support: Each theme has its own configuration.
  • Easy Theme Switching: Can be done at runtime based on tenant config or user preference.

Implementation Guide

  1. Define a TenantColors Data Class
data class TenantColors(
val primary: Color,
val onPrimary: Color,
val secondary: Color,
val onSecondary: Color,
val error: Color,
val onError: Color,
val background: Color,
val onBackground: Color,
val surface: Color,
val onSurface: Color,
)

2. Provide Light & Dark Color Sets per Tenant

val tenantA_lightColors = TenantColors(
primary = Color(0xFF1E88E5),
onPrimary = Color.White,
secondary = Color(0xFF26A69A),
onSecondary = Color.Black,
error = Color(0xFFD32F2F),
onError = Color.White,
background = Color.White,
onBackground = Color.Black,
surface = Color(0xFFF5F5F5),
onSurface = Color.Black,
)

val tenantA_darkColors = TenantColors(
primary = Color(0xFF90CAF9),
onPrimary = Color.Black,
secondary = Color(0xFF80CBC4),
onSecondary = Color.Black,
error = Color(0xFFEF9A9A),
onError = Color.Black,
background = Color(0xFF121212),
onBackground = Color.White,
surface = Color(0xFF1E1E1E),
onSurface = Color.White,
)

3. Convert to Material3 ColorScheme

fun TenantColors.toColorScheme(isDark: Boolean): ColorScheme {
return ColorScheme(
primary = primary,
onPrimary = onPrimary,
secondary = secondary,
onSecondary = onSecondary,
error = error,
onError = onError,
background = background,
onBackground = onBackground,
surface = surface,
onSurface = onSurface,
brightness = if (isDark) Brightness.Dark else Brightness.Light
)
}

4. Default Colors Before Backend Loads

Before fetching tenant-specific colors, the app shows a default fallback theme.

What the user sees:

  • A clean, neutral UI.
  • Uses standard Material 3 fallback colors like: Purple for primary, Teal for secondary and Red for error
  • Ensures visual consistency before dynamic colors load.

Default fallback:

val defaultColors = TenantColors(
primary = Color(0xFF6200EE),
onPrimary = Color.White,
secondary = Color(0xFF03DAC6),
onSecondary = Color.Black,
error = Color(0xFFB00020),
onError = Color.White,
background = Color.White,
onBackground = Color.Black,
surface = Color(0xFFF0F0F0),
onSurface = Color.Black,
)

This fallback theme can also represent the app’s default brand or splash theme.

5. Compose App Theme Wrapper

@Composable
fun AppTheme(
tenantColors: TenantColors,
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = tenantColors.toColorScheme(darkTheme)

MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content
)
}

6. Applying the Theme in Your App

@Composable
fun MyApp() {
val viewModel: ThemeViewModel = viewModel()
val tenantColors by viewModel.tenantColors.collectAsState(initial = defaultColors)
val isLoading by viewModel.isLoading.collectAsState()

AppTheme(tenantColors = tenantColors) {
if (isLoading) {
SplashScreen()
} else {
NavigationHost()
}
}
}

7. UI Components Use Abstract Colors

Button(
onClick = { /*...*/ },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text("Continue")
}

Unit Testing the Themes

  1. Color Mapping Unit Test
class ThemeMappingTest {

@Test
fun `verify light theme color mapping for tenant A`() {
val scheme = tenantA_lightColors.toColorScheme(isDark = false)

assertEquals(Color(0xFF1E88E5), scheme.primary)
assertEquals(Color(0xFF26A69A), scheme.secondary)
assertEquals(Color(0xFFF5F5F5), scheme.surface)
}

@Test
fun `verify fallback theme is applied initially`() {
val fallback = defaultColors.toColorScheme(false)

assertEquals(Color(0xFF6200EE), fallback.primary)
assertEquals(Color.White, fallback.onPrimary)
}
}

2. Compose UI Test

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun appTheme_appliesCorrectPrimaryColor() {
composeTestRule.setContent {
AppTheme(tenantColors = tenantA_lightColors) {
Box(
modifier = Modifier
.testTag("primaryBox")
.background(MaterialTheme.colorScheme.primary)
)
}
}

composeTestRule.onNodeWithTag("primaryBox")
.assertExists()
}

Integration Tips

  • Use ViewModel or Repository pattern to fetch and manage tenant theme config.
  • Integrate with Firebase Remote Config or backend API for dynamic updates.
  • Cache tenant colors in DataStore or Room for offline and instant startup.
  • Cover with unit tests to verify color mapping and fallback logic.

Final Thoughts

Material 3 and Jetpack Compose provide a powerful and flexible system for managing UI theming. By abstracting our color system and plugging in tenant-specific configurations, we can deliver a tailored user experience that aligns with each brand — without compromising on code clarity or maintainability.

Including a fallback theme ensures there’s no visual gap or inconsistency while the backend loads the actual theme, giving users a seamless and professional experience from the start.

📌 Reference:
Theming in Compose with Material 3 | Android Developers

--

--

The PickMe Engineering Blog
The PickMe Engineering Blog

Published in The PickMe Engineering Blog

At PickMe our purpose is to create joyful mobility experiences for our consumers. In this journey our technology leads the way and the solutions that we build help millions of customers and thousands of service partners to efficiently engage in a single platform.

No responses yet