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>
This commit is contained in:
+444
@@ -0,0 +1,444 @@
|
||||
# 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,
|
||||
);
|
||||
```
|
||||
Reference in New Issue
Block a user