Files
tatlock-ui/docs/THEMING.md
T
jpmschweitzerandClaude Opus 4.5 0c3239f4b0 docs: add Phase 0 documentation
- docs/ARCHITECTURE.md - Clean Architecture patterns and conventions
- docs/API_INTEGRATION.md - Core API and Tatlock API endpoints
- docs/DEPLOYMENT.md - Docker, NPM, Portainer setup
- docs/DATAGRID.md - DataGrid component specification
- docs/THEMING.md - Material 3 theming guide

Also update seed color to teal for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 16:26:44 +01:00

9.2 KiB

Theming Guide

This document describes the Material 3 theming system used in Tatlock UI.

Overview

Tatlock UI uses Material 3 (Material You) with:

  • Dynamic color schemes from a seed color
  • System preference detection (light/dark)
  • Manual override with persistence
  • Consistent use of colorScheme throughout

Theme Modes

System Preference (Default)

By default, the app follows the system's light/dark mode preference:

MaterialApp(
  themeMode: ThemeMode.system,
  theme: AppTheme.light,
  darkTheme: AppTheme.dark,
)

Manual Override

Users can override system preference. The choice persists via SharedPreferences:

enum ThemeSetting {
  system,  // Follow OS
  light,   // Always light
  dark,    // Always dark
}

Color Scheme

Seed Color

All colors derive from a single seed color for consistency:

const seedColor = Color(0xFF009688); // Teal

final lightScheme = ColorScheme.fromSeed(
  seedColor: seedColor,
  brightness: Brightness.light,
);

final darkScheme = ColorScheme.fromSeed(
  seedColor: seedColor,
  brightness: Brightness.dark,
);

Using Colors

Always use colorScheme - never use raw colors in widgets:

// Good
Container(
  color: Theme.of(context).colorScheme.surface,
  child: Text(
    'Hello',
    style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
  ),
)

// Bad - don't do this
Container(
  color: Colors.white,  // Won't adapt to dark mode
  child: Text('Hello', style: TextStyle(color: Colors.black)),
)

ColorScheme Roles

Role Usage
primary Key actions, selected states, important text
onPrimary Text/icons on primary backgrounds
primaryContainer Less prominent primary surfaces
secondary Secondary actions, accents
surface Background of cards, sheets, dialogs
onSurface Text on surface backgrounds
surfaceContainerHighest Elevated surfaces (cards on surface)
error Error states, destructive actions
outline Borders, dividers
outlineVariant Subtle borders

Semantic Colors

For domain-specific colors (status badges, charts), define semantic extensions:

extension SemanticColors on ColorScheme {
  Color get success => brightness == Brightness.light
      ? const Color(0xFF2E7D32)
      : const Color(0xFF81C784);

  Color get warning => brightness == Brightness.light
      ? const Color(0xFFF57C00)
      : const Color(0xFFFFB74D);

  Color get info => brightness == Brightness.light
      ? const Color(0xFF1976D2)
      : const Color(0xFF64B5F6);
}

// Usage
Container(color: Theme.of(context).colorScheme.success)

Theme Data

AppTheme Class

class AppTheme {
  AppTheme._();

  static const _seedColor = Color(0xFF009688); // Teal

  static final light = ThemeData(
    useMaterial3: true,
    colorScheme: ColorScheme.fromSeed(
      seedColor: _seedColor,
      brightness: Brightness.light,
    ),
    // Component themes below
  );

  static final dark = ThemeData(
    useMaterial3: true,
    colorScheme: ColorScheme.fromSeed(
      seedColor: _seedColor,
      brightness: Brightness.dark,
    ),
    // Component themes below
  );
}

Component Themes

Customize individual components while maintaining consistency:

ThemeData(
  // ...colorScheme...

  appBarTheme: AppBarTheme(
    centerTitle: false,
    elevation: 0,
    scrolledUnderElevation: 1,
  ),

  cardTheme: CardTheme(
    elevation: 0,
    shape: RoundedRectangleBorder(
      borderRadius: BorderRadius.circular(12),
      side: BorderSide(color: colorScheme.outlineVariant),
    ),
  ),

  inputDecorationTheme: InputDecorationTheme(
    filled: true,
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(8),
    ),
  ),

  filledButtonTheme: FilledButtonThemeData(
    style: FilledButton.styleFrom(
      minimumSize: const Size(88, 48),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(8),
      ),
    ),
  ),
)

State Management

ThemeNotifier

@riverpod
class ThemeNotifier extends _$ThemeNotifier {
  static const _key = 'theme_setting';

  @override
  ThemeSetting build() {
    _loadSavedSetting();
    return ThemeSetting.system;
  }

  Future<void> _loadSavedSetting() async {
    final prefs = await SharedPreferences.getInstance();
    final value = prefs.getString(_key);
    if (value != null) {
      state = ThemeSetting.values.byName(value);
    }
  }

  Future<void> setSetting(ThemeSetting setting) async {
    state = setting;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_key, setting.name);
  }

  ThemeMode get themeMode => switch (state) {
    ThemeSetting.system => ThemeMode.system,
    ThemeSetting.light => ThemeMode.light,
    ThemeSetting.dark => ThemeMode.dark,
  };
}

Using in App

class TatlockApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeNotifier = ref.watch(themeNotifierProvider.notifier);

    return MaterialApp.router(
      theme: AppTheme.light,
      darkTheme: AppTheme.dark,
      themeMode: themeNotifier.themeMode,
      routerConfig: router,
    );
  }
}

Typography

Text Styles

Use textTheme for consistent typography:

Text(
  'Heading',
  style: Theme.of(context).textTheme.headlineMedium,
)

Text(
  'Body text',
  style: Theme.of(context).textTheme.bodyLarge,
)

Text(
  'Label',
  style: Theme.of(context).textTheme.labelMedium,
)

Text Theme Scale

Style Size Weight Usage
displayLarge 57 400 Hero text
displayMedium 45 400 Large headers
displaySmall 36 400 Section headers
headlineLarge 32 400 Page titles
headlineMedium 28 400 Card titles
headlineSmall 24 400 Subheadings
titleLarge 22 400 App bar titles
titleMedium 16 500 List item titles
titleSmall 14 500 Tabs, chips
bodyLarge 16 400 Primary body text
bodyMedium 14 400 Secondary body text
bodySmall 12 400 Captions
labelLarge 14 500 Button text
labelMedium 12 500 Navigation labels
labelSmall 11 500 Badges, tags

Spacing

Use consistent spacing with a base unit:

class Spacing {
  Spacing._();

  static const double xs = 4;
  static const double sm = 8;
  static const double md = 16;
  static const double lg = 24;
  static const double xl = 32;
  static const double xxl = 48;
}

// Usage
Padding(
  padding: const EdgeInsets.all(Spacing.md),
  child: ...
)

SizedBox(height: Spacing.sm)

Border Radius

Consistent corner radii:

class Radii {
  Radii._();

  static const double sm = 4;
  static const double md = 8;
  static const double lg = 12;
  static const double xl = 16;
  static const double full = 9999;
}

// Usage
Container(
  decoration: BoxDecoration(
    borderRadius: BorderRadius.circular(Radii.md),
  ),
)

Elevation

Material 3 uses tonal elevation (surface tint) rather than shadows:

// Elevation levels
// 0 - Surface (no elevation)
// 1 - Slight elevation (cards, app bar scrolled)
// 2 - Moderate elevation (dialogs, menus)
// 3 - High elevation (navigation drawers)

Card(
  elevation: 0, // Use tonal surface instead of shadow
  color: Theme.of(context).colorScheme.surfaceContainerHighest,
)

Dark Mode Considerations

Contrast

Ensure sufficient contrast in dark mode:

  • Primary text: onSurface (high contrast)
  • Secondary text: onSurfaceVariant (medium contrast)
  • Disabled text: onSurface with opacity

Elevation in Dark Mode

Dark surfaces get lighter with elevation, not darker:

// M3 handles this automatically with surfaceContainerLow/High

Images

Consider providing dark mode variants for images/icons:

Image.asset(
  Theme.of(context).brightness == Brightness.dark
      ? 'assets/logo_dark.png'
      : 'assets/logo_light.png',
)

Testing Themes

Widget Tests

testWidgets('renders correctly in dark mode', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      theme: AppTheme.dark,
      home: const MyWidget(),
    ),
  );

  // Verify dark mode appearance
});

Golden Tests

Use golden tests to catch visual regressions:

testWidgets('matches golden in light mode', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      theme: AppTheme.light,
      home: const MyWidget(),
    ),
  );

  await expectLater(
    find.byType(MyWidget),
    matchesGoldenFile('goldens/my_widget_light.png'),
  );
});

flex_color_scheme (Optional)

For more sophisticated theming, consider flex_color_scheme:

import 'package:flex_color_scheme/flex_color_scheme.dart';

final lightTheme = FlexThemeData.light(
  scheme: FlexScheme.teal,
  surfaceMode: FlexSurfaceMode.levelSurfacesLowScaffold,
  blendLevel: 9,
  subThemesData: const FlexSubThemesData(
    blendOnLevel: 10,
    blendOnColors: false,
  ),
  useMaterial3: true,
);

final darkTheme = FlexThemeData.dark(
  scheme: FlexScheme.teal,
  surfaceMode: FlexSurfaceMode.levelSurfacesLowScaffold,
  blendLevel: 15,
  subThemesData: const FlexSubThemesData(
    blendOnLevel: 20,
  ),
  useMaterial3: true,
);