- 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>
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
colorSchemethroughout
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:
onSurfacewith 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,
);