skip to content

In a white-label Flutter loyalty app rebranded per retailer with dark mode, how would you structure ThemeData so no screen hardcodes a brand value?

level: seniorimportance: should knowfreq 32%

answer

  1. brand config as data, not code
  2. one theme factory: brand plus brightness
  3. fromSeed per brand, overrides for exact hues
  4. ThemeExtension for non-Material brand tokens
  5. component themes centralised, goldens per brand

basics

~20 s

Model each retailer as a small brand config (seed, optional exact colors, fonts, token values), and build light and dark ThemeData from it in one factory: fromSeed schemes, a TextTheme, component themes and ThemeExtension tokens. Screens only read Theme.of(context).

solid answer

~40 s

I keep a `BrandConfig` per retailer as data: seed color, any contractual exact colors, a font family name, and values for our own tokens. One function, `buildTheme(BrandConfig brand, Brightness brightness)`, returns a `ThemeData` with `ColorScheme.fromSeed` (using `fidelity` or explicit overrides when a hue must match), a `TextTheme`, every component theme we customise, and `ThemeExtension`s for tokens Material lacks. `MaterialApp` gets `theme` and `darkTheme` from it, and `themeMode` follows the user. Screens read roles and extensions only; I enforce that with a lint or a review rule against `Colors.*` and `Color(0x...)` outside the theme folder, and golden tests render key screens for every brand in both brightnesses. Translucent variants use `Color.withValues(alpha:)` on a role, never a new literal.

code

dart · 11 lines
dart
import 'package:flutter/material.dart';

extension BrandTokensX on BuildContext {
  LoyaltyTokens get loyaltyTokens {
    final LoyaltyTokens? tokens = Theme.of(this).extension<LoyaltyTokens>();
    if (tokens == null) {
      throw StateError('LoyaltyTokens missing from the active ThemeData');
    }
    return tokens;
  }
}

go deeper

for a junior

Recall that one ThemeData per brightness, built from the brand's seed, drives every screen, and that widgets never name a brand color.

for a middle

Explain the theme factory: fromSeed with brightness, component themes, ThemeExtension tokens and how exact brand hexes are overridden.

for a senior

Show the guardrails: lint rules against literals, golden tests per brand and brightness, a garish test brand, and reviewing local Theme overrides.

for a principal

Discuss build-time versus runtime branding and how far brands may diverge before a shared theme factory stops paying off.

## The goal A white-label app ships one codebase under many retailers' brands. Each build (or each runtime tenant) must look like its retailer in light and dark mode, and a new retailer should be a data change, not a code change. In Flutter the lever is that **every Material widget and every well-behaved custom widget reads `Theme.of(context)`**, so if the theme is right, the screens are right. ## Step 1: describe a brand as data Define a small immutable `BrandConfig`: - `seed` — the retailer's primary brand color. - Optional exact overrides — for example a contractual `primary` or `tertiary` hex. - `fontFamily` — a font already bundled with the app (bundling fonts is a separate topic). - Values for your own tokens — a points-badge color per brightness, a card radius, a tier gradient. Loading which brand is active (a build flavor, a remote config, a tenant ID) is plumbing outside theming; what matters is that it produces one `BrandConfig` before `MaterialApp` builds. ## Step 2: one theme factory ```dart import 'package:flutter/material.dart'; ThemeData buildTheme(BrandConfig brand, Brightness brightness) { final ColorScheme scheme = ColorScheme.fromSeed( seedColor: brand.seed, brightness: brightness, dynamicSchemeVariant: DynamicSchemeVariant.fidelity, primary: brand.exactPrimary, ); final ThemeData base = ThemeData( colorScheme: scheme, fontFamily: brand.fontFamily, ); return base.copyWith( filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(brand.cornerRadius), ), ), ), cardTheme: CardThemeData( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(brand.cornerRadius), ), ), extensions: <ThemeExtension<dynamic>>[ brand.tokensFor(brightness), ], ); } ``` Points worth defending in an interview: 1. **Same seed for both brightnesses.** Light and dark come from the same tonal palettes, so they read as one brand. `fidelity` keeps `primary` near the seed; an explicit `primary:` override pins it exactly, and you then check `onPrimary` against it. 2. **Component themes live here.** Shapes, paddings and any per-component color decisions go into `FilledButtonThemeData`, `CardThemeData`, `AppBarThemeData`, `InputDecorationThemeData` and the rest, so no screen passes `style:` for branding. 3. **Brand tokens Material lacks go into a `ThemeExtension`,** with a light and a dark instance and a real `lerp`, so they animate with the theme switch. 4. **Translucency derives from roles.** `scheme.primary.withValues(alpha: 0.12)` rather than a new hex; `withOpacity` has been deprecated since Flutter 3.27. `MaterialApp(theme: buildTheme(brand, Brightness.light), darkTheme: buildTheme(brand, Brightness.dark), themeMode: userMode)` completes the wiring. ## Step 3: keep screens honest | Guardrail | What it catches | |---|---| | Lint or grep rule banning `Colors.` and `Color(0x` outside the theme folder | New hardcoded brand values | | Golden tests per brand x brightness for key screens | Contrast and layout regressions in one variant | | A debug brand switcher in the app | Designers reviewing every retailer quickly | | Code review of `Theme(data: ThemeData(...))` | Local overrides that drop the brand theme | A useful habit is a "garish" test brand, an extreme seed such as pure magenta: any screen that still shows your default teal is reading a literal somewhere. ## Keeping the factory testable `buildTheme` is a pure function from `(BrandConfig, Brightness)` to `ThemeData`, which makes it easy to unit test: assert that every brand produces a scheme with the expected brightness, that every expected extension type is registered, and that overridden colors meet your contrast threshold against their `on*` partners. These tests are fast and run on every commit, while golden tests cover what screens actually render. ## Build-time versus runtime brands If every retailer ships as a separate store listing, the brand can be chosen at build time and compiled in. If one binary serves many retailers, the brand arrives at runtime, and the factory runs after the config loads; because `ThemeData` is plain data, nothing else changes. The strategic trade-off between the two lives with design-system theming; in Flutter the factory is identical either way. ## Pitfalls seen in practice - **Per-brand `if` statements in widgets** (`if (brand == acme) ...`) — the theme should absorb the difference. - **Overriding Material roles to mean brand concepts** — `tertiary` as "points color" leaks into components that also use `tertiary`. - **Only testing the default brand in light mode** — dark mode for the retailer with a pale seed is where contrast fails. - **Recreating `ThemeData` inside `build` on every frame** of a frequently rebuilding widget — build the two themes once per brand and reuse them. - **Forgetting the extension in `darkTheme`**, which makes `extension<T>()` return null only in dark mode. The result is a codebase where adding a retailer means adding one `BrandConfig`, running the golden suite, and shipping.

  • In a Flutter white-label app, how do you honour a retailer's exact brand hex without breaking Material 3 contrast pairs?
    Pass the hex as the `primary:` override to `ColorScheme.fromSeed` (or use it as the seed with `DynamicSchemeVariant.fidelity`), then verify `onPrimary` against it in both brightnesses. If the exact hex fails contrast in dark mode, keep it for logos and hero surfaces through a `ThemeExtension` token and let the generated dark `primary` drive components.
  • In Flutter, why build the two ThemeData objects once per brand instead of inside a frequently rebuilt widget's build?
    `ColorScheme.fromSeed` runs color-science math on every call, and the `Theme` compares old and new `ThemeData` field by field to decide whether dependents rebuild. An extension without value equality makes each fresh copy unequal, so every rebuild notifies all theme dependents. Building both themes once when the brand loads, and passing the same instances, avoids the repeated work.

saying these in an interview costs you the question

  • Branching on the retailer inside widgets instead of in the theme
  • Generating the dark scheme from a different seed than the light one
  • Using tertiary or secondary roles to mean brand-specific concepts
  • Testing golden screenshots for the default brand in light mode only
  • Deriving translucent brand colors with withOpacity in new code