buildTheme function

ThemeData buildTheme(
  1. WalletColors colors, {
  2. TextTheme? textTheme,
  3. Iterable<ThemeExtension<ThemeExtension>> extensions = const [],
})

Build a complete ThemeData from a WalletColors scheme.

The SDK bundles no font (FR-49 W-9, ADR-0564): with no textTheme the text is set in the platform's default face. A host that wants its own faces passes a textTheme; its non-null values are merged over walletTextScale, so a host may set only families and keep the SDK's sizes. The mono face for identifiers is WalletTypography, the glyphs are WalletIcons, and the corner radii are WalletShapes; pass them in extensions to override the defaults.

The component themes (buttons, fields, sheets, dialogs, chips, progress, toggles; Relim design-refresh spec §3.4, stage S11 C5) mirror Relim's own ThemeData, so a host on this theme and Relim on its own look alike. The SDK's widgets carry no style literal of their own: in a host with its own theme, the host's component themes decide.

Implementation

ThemeData buildTheme(
  WalletColors colors, {
  TextTheme? textTheme,
  Iterable<ThemeExtension> extensions = const [],
}) {
  final scale = walletTextScale(colors);
  final resolvedText = textTheme == null ? scale : scale.merge(textTheme);

  final isDark = colors.bg.computeLuminance() < 0.5;

  // The radii the component themes below are cut to: the host's, or the
  // defaults for the platform this ThemeData resolves to (it reads the same
  // `defaultTargetPlatform` when no platform is given).
  final shapes =
      extensions.whereType<WalletShapes>().firstOrNull ??
      WalletShapes.forPlatform(defaultTargetPlatform);

  // One extension per type: a host-supplied WalletTypography, WalletIcons or
  // WalletShapes replaces the SDK default; WalletColors is always [colors].
  final resolvedExtensions = <ThemeExtension>[
    if (!extensions.any((e) => e is WalletTypography))
      WalletTypography.fallback,
    if (!extensions.any((e) => e is WalletIcons)) WalletIcons.material,
    if (!extensions.any((e) => e is WalletShapes)) shapes,
    ...extensions.where((e) => e is! WalletColors),
    colors,
  ];

  // §3.4: every button is a pill at least 48 high, labelled in labelLarge.
  const buttonMin = Size(48, 48);
  const pill = StadiumBorder();
  final buttonLabel = resolvedText.labelLarge;
  OutlineInputBorder field(Color c, double w) => OutlineInputBorder(
    borderRadius: BorderRadius.circular(shapes.field),
    borderSide: BorderSide(color: c, width: w),
  );
  // `outline` is `toggleOff`: the SDK has no `borderStrong`, and S11 adds no
  // colour field (C5).
  final outline = colors.toggleOff;

  return ThemeData(
    brightness: isDark ? Brightness.dark : Brightness.light,
    scaffoldBackgroundColor: colors.bg,
    // The refreshed design's roles (Relim design-refresh spec §3.3).
    colorScheme: ColorScheme(
      brightness: isDark ? Brightness.dark : Brightness.light,
      primary: colors.accent,
      onPrimary: colors.onAccent,
      primaryContainer: colors.accentSoft,
      onPrimaryContainer: colors.accentText,
      // Positive notes and tonal buttons use the brand's soft tint (mint), one
      // colour for one meaning (the refresh folds success into the brand).
      secondary: colors.accent,
      onSecondary: colors.onAccent,
      secondaryContainer: colors.accentSoft,
      onSecondaryContainer: colors.accentText,
      tertiary: colors.orange,
      // MONEY-LENS FOLD: a dark palette's warning/danger are BRIGHT fills, so
      // their on-colours are dark in dark themes and white in light. Gated by
      // the contrast tests — colorScheme pairs included, not just raw tokens.
      onTertiary: isDark ? colors.bg : colors.onAccent,
      // CONTAINER ROLES: a low-alpha tint of the warning hue
      // over the card surface, so a caution note never reads like a positive
      // one. The refreshed spec copies this derivation (§3.3).
      tertiaryContainer: Color.alphaBlend(
        colors.orange.withValues(alpha: isDark ? 0.20 : 0.13),
        colors.bgCard,
      ),
      onTertiaryContainer: colors.text,
      error: colors.red,
      onError: isDark ? colors.bg : colors.onAccent,
      surface: colors.bgCard,
      onSurface: colors.text,
      onSurfaceVariant: colors.textMuted,
      surfaceContainerLowest: colors.bg,
      surfaceContainerLow: colors.bgCard,
      surfaceContainerHigh: colors.bgHover,
      surfaceContainerHighest: colors.bgHover,
      outline: colors.toggleOff,
      outlineVariant: colors.border,
      inverseSurface: colors.text,
      onInverseSurface: colors.bg,
    ),
    textTheme: resolvedText,
    appBarTheme: AppBarTheme(
      backgroundColor: colors.bg,
      foregroundColor: colors.text,
      elevation: 0,
      scrolledUnderElevation: 0,
      systemOverlayStyle: overlayStyleFor(colors),
    ),
    bottomNavigationBarTheme: BottomNavigationBarThemeData(
      backgroundColor: colors.bg,
      selectedItemColor: colors.accent,
      unselectedItemColor: colors.inactiveElement,
      selectedLabelStyle: resolvedText.labelSmall?.copyWith(fontSize: 11),
      unselectedLabelStyle: resolvedText.labelSmall?.copyWith(fontSize: 11),
      type: BottomNavigationBarType.fixed,
      elevation: 0,
    ),
    tabBarTheme: TabBarThemeData(
      labelColor: colors.accent,
      unselectedLabelColor: colors.textMuted,
      indicatorColor: colors.accent,
    ),
    dividerTheme: DividerThemeData(color: colors.border, thickness: 1),
    cardTheme: CardThemeData(
      color: colors.bgCard,
      elevation: 0,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(shapes.group),
      ),
    ),
    filledButtonTheme: FilledButtonThemeData(
      style: FilledButton.styleFrom(
        minimumSize: buttonMin,
        shape: pill,
        textStyle: buttonLabel,
      ),
    ),
    outlinedButtonTheme: OutlinedButtonThemeData(
      style: OutlinedButton.styleFrom(
        foregroundColor: colors.text,
        minimumSize: buttonMin,
        shape: pill,
        side: BorderSide(color: outline, width: 1.5),
        textStyle: buttonLabel,
      ),
    ),
    textButtonTheme: TextButtonThemeData(
      style: TextButton.styleFrom(
        minimumSize: buttonMin,
        shape: pill,
        textStyle: buttonLabel,
      ),
    ),
    iconButtonTheme: IconButtonThemeData(
      style: IconButton.styleFrom(minimumSize: const Size(44, 44)),
    ),
    inputDecorationTheme: InputDecorationThemeData(
      filled: true,
      fillColor: colors.bgCard,
      border: field(outline, 1),
      enabledBorder: field(outline, 1),
      focusedBorder: field(colors.accent, 2),
      errorBorder: field(colors.red, 1),
      focusedErrorBorder: field(colors.red, 2),
      disabledBorder: field(colors.border, 1),
    ),
    bottomSheetTheme: BottomSheetThemeData(
      backgroundColor: colors.bgCard,
      modalBackgroundColor: colors.bgCard,
      dragHandleColor: outline,
      constraints: const BoxConstraints(maxWidth: walletSheetMaxWidth),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(shapes.sheet)),
      ),
    ),
    // S13 Build B (DESIGN §6.9): the title 600 at 20 (the `titleLarge` role,
    // re-weighted), the body at 16 (the `bodyLarge` role, in `text`). From the
    // resolved roles, so a host text theme still decides the face and size.
    // The date pickers are Material dialogs of their own (not AlertDialog)
    // and do not read these two styles.
    dialogTheme: DialogThemeData(
      backgroundColor: colors.bgCard,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(shapes.dialog),
      ),
      titleTextStyle: resolvedText.titleLarge?.copyWith(
        fontWeight: FontWeight.w600,
        color: colors.text,
      ),
      contentTextStyle: resolvedText.bodyLarge?.copyWith(color: colors.text),
    ),
    snackBarTheme: SnackBarThemeData(
      behavior: SnackBarBehavior.floating,
      backgroundColor: colors.text,
      contentTextStyle: resolvedText.bodyMedium?.copyWith(color: colors.bg),
      shape: pill,
    ),
    switchTheme: SwitchThemeData(
      trackColor: WidgetStateProperty.resolveWith(
        (states) => states.contains(WidgetState.selected)
            ? colors.accent
            : colors.bgHover,
      ),
      trackOutlineColor: WidgetStateProperty.resolveWith(
        (states) => states.contains(WidgetState.selected)
            ? Colors.transparent
            : colors.textMuted,
      ),
      trackOutlineWidth: const WidgetStatePropertyAll(2),
      thumbColor: WidgetStateProperty.resolveWith(
        (states) => states.contains(WidgetState.selected)
            ? colors.onAccent
            : colors.textMuted,
      ),
    ),
    radioTheme: RadioThemeData(
      fillColor: WidgetStateProperty.resolveWith(
        (states) => states.contains(WidgetState.selected)
            ? colors.accent
            : colors.textMuted,
      ),
    ),
    // Relim's 6 dp checkbox corner has no WalletShapes role, and a literal
    // radius is banned outside shapes.dart, so the box keeps Material's shape.
    checkboxTheme: CheckboxThemeData(
      fillColor: WidgetStateProperty.resolveWith(
        (states) => states.contains(WidgetState.selected)
            ? colors.accent
            : Colors.transparent,
      ),
      checkColor: WidgetStatePropertyAll(colors.onAccent),
    ),
    segmentedButtonTheme: SegmentedButtonThemeData(
      style: SegmentedButton.styleFrom(
        backgroundColor: colors.bgHover,
        foregroundColor: colors.text,
        selectedBackgroundColor: colors.accentSoft,
        selectedForegroundColor: colors.accentText,
        side: BorderSide.none,
        shape: pill,
        textStyle: resolvedText.labelMedium,
      ),
    ),
    chipTheme: ChipThemeData(
      backgroundColor: Colors.transparent,
      selectedColor: colors.accentSoft,
      labelStyle: resolvedText.labelMedium,
      side: BorderSide(color: outline),
      shape: pill,
      checkmarkColor: colors.accentText,
    ),
    // `cyan` on `outline`, 6 high (§9.4 item 3). The sync STATE is carried by
    // the tinted glyph beside the bar, not the bar's colour.
    progressIndicatorTheme: ProgressIndicatorThemeData(
      color: colors.cyan,
      linearTrackColor: outline,
      linearMinHeight: 6,
      borderRadius: BorderRadius.circular(shapes.progress),
    ),
    listTileTheme: const ListTileThemeData(minTileHeight: 52),
    extensions: resolvedExtensions,
  );
}