- 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>
445 lines
9.2 KiB
Markdown
445 lines
9.2 KiB
Markdown
# 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:
|
|
|
|
```dart
|
|
MaterialApp(
|
|
themeMode: ThemeMode.system,
|
|
theme: AppTheme.light,
|
|
darkTheme: AppTheme.dark,
|
|
)
|
|
```
|
|
|
|
### Manual Override
|
|
|
|
Users can override system preference. The choice persists via SharedPreferences:
|
|
|
|
```dart
|
|
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:
|
|
|
|
```dart
|
|
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:
|
|
|
|
```dart
|
|
// 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:
|
|
|
|
```dart
|
|
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
|
|
|
|
```dart
|
|
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:
|
|
|
|
```dart
|
|
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
|
|
|
|
```dart
|
|
@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
|
|
|
|
```dart
|
|
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:
|
|
|
|
```dart
|
|
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:
|
|
|
|
```dart
|
|
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:
|
|
|
|
```dart
|
|
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:
|
|
|
|
```dart
|
|
// 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:
|
|
|
|
```dart
|
|
// M3 handles this automatically with surfaceContainerLow/High
|
|
```
|
|
|
|
### Images
|
|
|
|
Consider providing dark mode variants for images/icons:
|
|
|
|
```dart
|
|
Image.asset(
|
|
Theme.of(context).brightness == Brightness.dark
|
|
? 'assets/logo_dark.png'
|
|
: 'assets/logo_light.png',
|
|
)
|
|
```
|
|
|
|
## Testing Themes
|
|
|
|
### Widget Tests
|
|
|
|
```dart
|
|
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:
|
|
|
|
```dart
|
|
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`:
|
|
|
|
```dart
|
|
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,
|
|
);
|
|
```
|