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:
@@ -0,0 +1,424 @@
|
|||||||
|
# API Integration Guide
|
||||||
|
|
||||||
|
This document describes the backend APIs that Tatlock UI integrates with.
|
||||||
|
|
||||||
|
## Backend Services
|
||||||
|
|
||||||
|
| Service | URL | Purpose |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| Core API | `https://api.schweitz.net` | Infrastructure, widgets, housekeeping |
|
||||||
|
| Tatlock API | `https://tatlock.schweitz.net` | LLM chat completions, streaming |
|
||||||
|
|
||||||
|
Both APIs are behind Authentik SSO - requests must include a valid Bearer token.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Authentik OIDC Flow
|
||||||
|
|
||||||
|
Tatlock UI uses the Authorization Code flow with PKCE:
|
||||||
|
|
||||||
|
1. User clicks "Sign In"
|
||||||
|
2. App redirects to Authentik authorization endpoint
|
||||||
|
3. User authenticates with Authentik
|
||||||
|
4. Authentik redirects back with authorization code
|
||||||
|
5. App exchanges code for tokens
|
||||||
|
6. Access token used for API requests, refresh token for renewal
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Provider | Authentik |
|
||||||
|
| Client ID | `tatlock-ui` |
|
||||||
|
| Client Type | Public (PKCE) |
|
||||||
|
| Scopes | `openid profile email` |
|
||||||
|
| Discovery URL | `https://auth.schweitz.net/application/o/tatlock-ui/.well-known/openid-configuration` |
|
||||||
|
|
||||||
|
### Token Usage
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Include in all API requests
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $accessToken',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core API Endpoints
|
||||||
|
|
||||||
|
Base URL: `https://api.schweitz.net`
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
#### Get System Metrics
|
||||||
|
```
|
||||||
|
GET /infrastructure/resources/system
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cpu": {
|
||||||
|
"percent": 23.5,
|
||||||
|
"cores": 8
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"percent": 45.2,
|
||||||
|
"total_gb": 32.0,
|
||||||
|
"used_gb": 14.5
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"percent": 67.8,
|
||||||
|
"total_gb": 500.0,
|
||||||
|
"used_gb": 339.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List Containers
|
||||||
|
```
|
||||||
|
GET /infrastructure/containers
|
||||||
|
```
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- `status` (optional): Filter by status (running, stopped, paused)
|
||||||
|
- `search` (optional): Search by name
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "abc123...",
|
||||||
|
"name": "tatlock-api",
|
||||||
|
"status": "running",
|
||||||
|
"image": "ghcr.io/jpmschweitzer/tatlock:latest",
|
||||||
|
"created": "2024-12-01T10:00:00Z",
|
||||||
|
"ports": ["8000:8000"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Container Details
|
||||||
|
```
|
||||||
|
GET /infrastructure/containers/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Container Logs
|
||||||
|
```
|
||||||
|
GET /infrastructure/containers/{id}/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- `tail` (optional): Number of lines (default: 100)
|
||||||
|
- `since` (optional): ISO timestamp
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"logs": "2024-12-30 10:00:00 INFO Starting server...\n..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Container Actions
|
||||||
|
```
|
||||||
|
POST /infrastructure/containers/{id}/{action}
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions: `start`, `stop`, `restart`, `pause`, `unpause`
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Container restarted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Container Resources
|
||||||
|
```
|
||||||
|
GET /infrastructure/resources/containers
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "abc123...",
|
||||||
|
"name": "tatlock-api",
|
||||||
|
"cpu_percent": 2.5,
|
||||||
|
"memory_mb": 256,
|
||||||
|
"memory_limit_mb": 1024
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
#### Get Widget Data
|
||||||
|
```
|
||||||
|
GET /infrastructure/widget-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"name": "Infrastructure",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": "Portainer",
|
||||||
|
"url": "https://portainer.schweitz.net",
|
||||||
|
"icon": "portainer",
|
||||||
|
"status": "up"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Health Check
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": "2024-12-30T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Housekeeping (Home Assistant)
|
||||||
|
|
||||||
|
#### List Devices
|
||||||
|
```
|
||||||
|
GET /housekeeping/devices
|
||||||
|
```
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- `area` (optional): Filter by area name
|
||||||
|
- `domain` (optional): Filter by domain (light, switch, climate, etc.)
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entity_id": "light.living_room",
|
||||||
|
"friendly_name": "Living Room Light",
|
||||||
|
"domain": "light",
|
||||||
|
"state": "on",
|
||||||
|
"area": "Living Room",
|
||||||
|
"attributes": {
|
||||||
|
"brightness": 255,
|
||||||
|
"color_temp": 370
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List Areas
|
||||||
|
```
|
||||||
|
GET /housekeeping/areas
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "living_room",
|
||||||
|
"name": "Living Room",
|
||||||
|
"device_count": 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List Scenes
|
||||||
|
```
|
||||||
|
GET /housekeeping/scenes
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entity_id": "scene.movie_time",
|
||||||
|
"friendly_name": "Movie Time",
|
||||||
|
"area": "Living Room"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Control Device
|
||||||
|
```
|
||||||
|
POST /housekeeping/devices/{entity_id}/control
|
||||||
|
```
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "turn_on",
|
||||||
|
"attributes": {
|
||||||
|
"brightness": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Activate Scene
|
||||||
|
```
|
||||||
|
POST /housekeeping/scenes/{scene_id}/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tatlock API Endpoints
|
||||||
|
|
||||||
|
Base URL: `https://tatlock.schweitz.net`
|
||||||
|
|
||||||
|
### Chat Completions (Streaming)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/chat/completions
|
||||||
|
```
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "tatlock",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are Tatlock, a helpful butler."},
|
||||||
|
{"role": "user", "content": "What's the weather like?"}
|
||||||
|
],
|
||||||
|
"stream": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (SSE stream):
|
||||||
|
```
|
||||||
|
data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"The"}}]}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-123","choices":[{"delta":{"content":" weather"}}]}
|
||||||
|
|
||||||
|
data: {"id":"chatcmpl-123","choices":[{"delta":{"reasoning_content":"Checking weather API..."}}]}
|
||||||
|
|
||||||
|
data: [DONE]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important fields:**
|
||||||
|
- `delta.content` - Main response text
|
||||||
|
- `delta.reasoning_content` - Thinking/reasoning (show in collapsible block)
|
||||||
|
|
||||||
|
### List Models
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "tatlock",
|
||||||
|
"object": "model",
|
||||||
|
"owned_by": "local"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSE Streaming Implementation
|
||||||
|
|
||||||
|
For chat completions, use Server-Sent Events:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Platform-aware SSE client
|
||||||
|
class SseClient {
|
||||||
|
Stream<ChatCompletionChunk> streamCompletion(ChatCompletionRequest request) async* {
|
||||||
|
final response = await _client.post(
|
||||||
|
'/v1/chat/completions',
|
||||||
|
data: request.toJson(),
|
||||||
|
options: Options(
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
headers: {'Accept': 'text/event-stream'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await for (final chunk in response.data.stream) {
|
||||||
|
final lines = utf8.decode(chunk).split('\n');
|
||||||
|
for (final line in lines) {
|
||||||
|
if (line.startsWith('data: ') && line != 'data: [DONE]') {
|
||||||
|
final json = jsonDecode(line.substring(6));
|
||||||
|
yield ChatCompletionChunk.fromJson(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Standard Error Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "CONTAINER_NOT_FOUND",
|
||||||
|
"message": "Container with ID 'xyz' not found",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP Status Codes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 200 | Success |
|
||||||
|
| 400 | Bad Request - Invalid parameters |
|
||||||
|
| 401 | Unauthorized - Token expired or invalid |
|
||||||
|
| 403 | Forbidden - Insufficient permissions |
|
||||||
|
| 404 | Not Found - Resource doesn't exist |
|
||||||
|
| 500 | Server Error - Backend issue |
|
||||||
|
|
||||||
|
### Handling in App
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ApiException implements Exception {
|
||||||
|
ApiException({required this.code, required this.message});
|
||||||
|
|
||||||
|
final String code;
|
||||||
|
final String message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In interceptor
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
// Trigger token refresh or re-auth
|
||||||
|
throw AuthException();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Currently no rate limiting on internal APIs. For LLM endpoints, be mindful of:
|
||||||
|
- Concurrent requests (limit to 1 active chat stream)
|
||||||
|
- Token consumption (context window limits)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
Interactive API docs available at:
|
||||||
|
- Core API: https://api.schweitz.net/docs
|
||||||
|
- Tatlock API: https://tatlock.schweitz.net/docs
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# Architecture Guide
|
||||||
|
|
||||||
|
This document defines the Clean Architecture patterns and conventions used in Tatlock UI.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Tatlock UI follows **Clean Architecture** with feature-based organization. This separation ensures testability, maintainability, and clear boundaries between concerns.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── main.dart # Entry point
|
||||||
|
├── version.g.dart # Generated version info
|
||||||
|
├── app.dart # MaterialApp.router setup
|
||||||
|
├── core/ # Shared infrastructure
|
||||||
|
│ ├── api/ # HTTP client, interceptors, SSE
|
||||||
|
│ ├── auth/ # Authentik OIDC integration
|
||||||
|
│ ├── config/ # Environment configuration
|
||||||
|
│ ├── error/ # Error types and handling
|
||||||
|
│ └── theme/ # Material 3 theming
|
||||||
|
├── routing/ # go_router configuration
|
||||||
|
├── shared/ # Reusable components
|
||||||
|
│ ├── components/ # DataGrid, common widgets
|
||||||
|
│ └── layouts/ # App scaffold, navigation
|
||||||
|
└── features/ # Feature modules
|
||||||
|
├── dashboard/
|
||||||
|
├── chat/
|
||||||
|
├── containers/
|
||||||
|
└── housekeeping/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Structure
|
||||||
|
|
||||||
|
Each feature follows a three-layer architecture:
|
||||||
|
|
||||||
|
```
|
||||||
|
features/{feature}/
|
||||||
|
├── presentation/ # UI Layer
|
||||||
|
│ ├── pages/ # Full-screen route widgets
|
||||||
|
│ ├── widgets/ # Feature-specific widgets
|
||||||
|
│ └── providers/ # Riverpod providers/notifiers
|
||||||
|
├── domain/ # Business Logic Layer
|
||||||
|
│ ├── entities/ # Immutable business objects
|
||||||
|
│ ├── repositories/ # Abstract repository interfaces
|
||||||
|
│ └── usecases/ # Single-purpose business operations
|
||||||
|
└── data/ # Data Layer
|
||||||
|
├── models/ # JSON-serializable DTOs
|
||||||
|
├── datasources/ # API clients, local storage
|
||||||
|
└── repositories/ # Repository implementations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layer Rules
|
||||||
|
|
||||||
|
### Domain Layer (Pure Dart)
|
||||||
|
|
||||||
|
The domain layer is the core of each feature. It has **no dependencies** on Flutter, external packages, or other layers.
|
||||||
|
|
||||||
|
**Entities** - Immutable business objects:
|
||||||
|
```dart
|
||||||
|
// domain/entities/container.dart
|
||||||
|
class Container {
|
||||||
|
const Container({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.status,
|
||||||
|
required this.image,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final ContainerStatus status;
|
||||||
|
final String image;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContainerStatus { running, stopped, paused, restarting }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Repository Interfaces** - Abstract contracts:
|
||||||
|
```dart
|
||||||
|
// domain/repositories/container_repository.dart
|
||||||
|
abstract class ContainerRepository {
|
||||||
|
Future<List<Container>> getContainers();
|
||||||
|
Future<Container> getContainer(String id);
|
||||||
|
Future<void> startContainer(String id);
|
||||||
|
Future<void> stopContainer(String id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Cases** - Single-purpose operations (optional, for complex logic):
|
||||||
|
```dart
|
||||||
|
// domain/usecases/restart_unhealthy_containers.dart
|
||||||
|
class RestartUnhealthyContainers {
|
||||||
|
RestartUnhealthyContainers(this._repository);
|
||||||
|
|
||||||
|
final ContainerRepository _repository;
|
||||||
|
|
||||||
|
Future<int> call() async {
|
||||||
|
final containers = await _repository.getContainers();
|
||||||
|
final unhealthy = containers.where((c) => c.status == ContainerStatus.stopped);
|
||||||
|
|
||||||
|
for (final container in unhealthy) {
|
||||||
|
await _repository.startContainer(container.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unhealthy.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Layer (External Dependencies)
|
||||||
|
|
||||||
|
The data layer implements domain interfaces and handles external communication.
|
||||||
|
|
||||||
|
**Models** - JSON-serializable DTOs:
|
||||||
|
```dart
|
||||||
|
// data/models/container_model.dart
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'container_model.freezed.dart';
|
||||||
|
part 'container_model.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class ContainerModel with _$ContainerModel {
|
||||||
|
const factory ContainerModel({
|
||||||
|
required String id,
|
||||||
|
required String name,
|
||||||
|
required String status,
|
||||||
|
required String image,
|
||||||
|
}) = _ContainerModel;
|
||||||
|
|
||||||
|
factory ContainerModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ContainerModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ContainerModelX on ContainerModel {
|
||||||
|
Container toEntity() => Container(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
status: ContainerStatus.values.byName(status),
|
||||||
|
image: image,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Sources** - API clients:
|
||||||
|
```dart
|
||||||
|
// data/datasources/containers_datasource.dart
|
||||||
|
class ContainersDatasource {
|
||||||
|
ContainersDatasource(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
Future<List<ContainerModel>> getContainers() async {
|
||||||
|
final response = await _client.get('/infrastructure/containers');
|
||||||
|
return (response.data as List)
|
||||||
|
.map((json) => ContainerModel.fromJson(json))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Repository Implementations**:
|
||||||
|
```dart
|
||||||
|
// data/repositories/container_repository_impl.dart
|
||||||
|
class ContainerRepositoryImpl implements ContainerRepository {
|
||||||
|
ContainerRepositoryImpl(this._datasource);
|
||||||
|
|
||||||
|
final ContainersDatasource _datasource;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Container>> getContainers() async {
|
||||||
|
final models = await _datasource.getContainers();
|
||||||
|
return models.map((m) => m.toEntity()).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Presentation Layer (Flutter + Riverpod)
|
||||||
|
|
||||||
|
The presentation layer contains UI code and state management.
|
||||||
|
|
||||||
|
**Providers** - Riverpod state management:
|
||||||
|
```dart
|
||||||
|
// presentation/providers/containers_provider.dart
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
part 'containers_provider.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class ContainersNotifier extends _$ContainersNotifier {
|
||||||
|
@override
|
||||||
|
Future<List<Container>> build() async {
|
||||||
|
final repository = ref.watch(containerRepositoryProvider);
|
||||||
|
return repository.getContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(() => ref.read(containerRepositoryProvider).getContainers());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages** - Full-screen routes:
|
||||||
|
```dart
|
||||||
|
// presentation/pages/containers_page.dart
|
||||||
|
class ContainersPage extends ConsumerWidget {
|
||||||
|
const ContainersPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final containersAsync = ref.watch(containersNotifierProvider);
|
||||||
|
|
||||||
|
return containersAsync.when(
|
||||||
|
data: (containers) => ContainersList(containers: containers),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, stack) => ErrorDisplay(error: error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Widgets** - Feature-specific UI components:
|
||||||
|
```dart
|
||||||
|
// presentation/widgets/container_card.dart
|
||||||
|
class ContainerCard extends StatelessWidget {
|
||||||
|
const ContainerCard({super.key, required this.container});
|
||||||
|
|
||||||
|
final Container container;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Widget implementation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Presentation → Domain ← Data
|
||||||
|
↓ ↑ ↓
|
||||||
|
Riverpod Interfaces Dio/API
|
||||||
|
```
|
||||||
|
|
||||||
|
- Presentation depends on Domain (uses entities, calls repository interfaces)
|
||||||
|
- Data depends on Domain (implements repository interfaces)
|
||||||
|
- Domain depends on nothing (pure Dart)
|
||||||
|
|
||||||
|
## Dependency Injection
|
||||||
|
|
||||||
|
Use Riverpod for all dependency injection:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Datasource provider
|
||||||
|
@riverpod
|
||||||
|
ContainersDatasource containersDatasource(Ref ref) {
|
||||||
|
return ContainersDatasource(ref.watch(apiClientProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository provider (returns interface type)
|
||||||
|
@riverpod
|
||||||
|
ContainerRepository containerRepository(Ref ref) {
|
||||||
|
return ContainerRepositoryImpl(ref.watch(containersDatasourceProvider));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Naming Conventions
|
||||||
|
|
||||||
|
| Type | Convention | Example |
|
||||||
|
|------|------------|---------|
|
||||||
|
| Pages | `{name}_page.dart` | `containers_page.dart` |
|
||||||
|
| Widgets | `{name}_{type}.dart` | `container_card.dart` |
|
||||||
|
| Providers | `{name}_provider.dart` | `containers_provider.dart` |
|
||||||
|
| Entities | `{name}.dart` | `container.dart` |
|
||||||
|
| Models | `{name}_model.dart` | `container_model.dart` |
|
||||||
|
| Repositories | `{name}_repository.dart` | `container_repository.dart` |
|
||||||
|
| Datasources | `{name}_datasource.dart` | `containers_datasource.dart` |
|
||||||
|
| Use Cases | `{action}_{noun}.dart` | `restart_container.dart` |
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
Each layer has specific testing requirements:
|
||||||
|
|
||||||
|
### Domain Layer
|
||||||
|
- **Unit tests** for all entities and use cases
|
||||||
|
- No mocking needed (pure Dart)
|
||||||
|
- 100% coverage expected
|
||||||
|
|
||||||
|
### Data Layer
|
||||||
|
- **Unit tests** for model serialization
|
||||||
|
- **Integration tests** for datasources (mock HTTP)
|
||||||
|
- Test entity conversions
|
||||||
|
|
||||||
|
### Presentation Layer
|
||||||
|
- **Widget tests** for all pages and complex widgets
|
||||||
|
- **Provider tests** for state management logic
|
||||||
|
- Mock repositories using `mocktail`
|
||||||
|
|
||||||
|
## Code Generation
|
||||||
|
|
||||||
|
Run after modifying annotated code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated files:
|
||||||
|
- `*.freezed.dart` - Immutable classes
|
||||||
|
- `*.g.dart` - JSON serialization, Riverpod providers
|
||||||
|
|
||||||
|
## Import Rules
|
||||||
|
|
||||||
|
1. Never import from `data/` in `domain/`
|
||||||
|
2. Never import from `presentation/` in `domain/` or `data/`
|
||||||
|
3. Import entities from `domain/`, not models from `data/`
|
||||||
|
4. Use relative imports within a feature, package imports across features
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Good - within feature
|
||||||
|
import '../domain/entities/container.dart';
|
||||||
|
|
||||||
|
// Good - across features
|
||||||
|
import 'package:tatlock_ui/features/containers/domain/entities/container.dart';
|
||||||
|
|
||||||
|
// Bad - importing model in presentation
|
||||||
|
import '../data/models/container_model.dart'; // Don't do this
|
||||||
|
```
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
# DataGrid Component System
|
||||||
|
|
||||||
|
This document specifies the reusable DataGrid component for consistent table UIs across all features.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The DataGrid system provides a declarative, type-safe way to display tabular data with:
|
||||||
|
- Configurable columns with custom renderers
|
||||||
|
- Row selection (single and multi-select)
|
||||||
|
- Sorting and searching
|
||||||
|
- Per-row and bulk actions
|
||||||
|
- Pagination and infinite scroll
|
||||||
|
- Integration with Core API
|
||||||
|
|
||||||
|
Inspired by: [fframe ListGrid](https://github.com/postmeridiem/fframe/tree/main/fframe/lib/screens/listgrid_screen)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```dart
|
||||||
|
DataGrid<Container>(
|
||||||
|
config: DataGridConfig(
|
||||||
|
columns: [
|
||||||
|
DataGridColumn(
|
||||||
|
header: 'Name',
|
||||||
|
valueBuilder: (c) => c.name,
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'name',
|
||||||
|
),
|
||||||
|
DataGridColumn(
|
||||||
|
header: 'Status',
|
||||||
|
valueBuilder: (c) => c.status.name,
|
||||||
|
cellBuilder: (context, c) => ContainerStatusBadge(status: c.status),
|
||||||
|
),
|
||||||
|
DataGridColumn(
|
||||||
|
header: 'Image',
|
||||||
|
valueBuilder: (c) => c.image,
|
||||||
|
width: DataGridColumnWidth.flex(2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
DataGridAction(
|
||||||
|
icon: Icons.play_arrow,
|
||||||
|
label: 'Start',
|
||||||
|
onTap: (c) => ref.read(containerActionsProvider).start(c.id),
|
||||||
|
showWhen: (c) => c.status == ContainerStatus.stopped,
|
||||||
|
),
|
||||||
|
DataGridAction(
|
||||||
|
icon: Icons.stop,
|
||||||
|
label: 'Stop',
|
||||||
|
onTap: (c) => ref.read(containerActionsProvider).stop(c.id),
|
||||||
|
showWhen: (c) => c.status == ContainerStatus.running,
|
||||||
|
destructive: true,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rowsSelectable: true,
|
||||||
|
enableSearch: true,
|
||||||
|
onRowTap: (c) => context.push('/containers/${c.id}'),
|
||||||
|
),
|
||||||
|
source: ContainersDataSource(ref.watch(apiClientProvider)),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Classes
|
||||||
|
|
||||||
|
### DataGridConfig
|
||||||
|
|
||||||
|
Main configuration for a data grid instance.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DataGridConfig<T> {
|
||||||
|
const DataGridConfig({
|
||||||
|
required this.columns,
|
||||||
|
this.actions = const [],
|
||||||
|
this.bulkActions = const [],
|
||||||
|
this.rowsSelectable = false,
|
||||||
|
this.showHeader = true,
|
||||||
|
this.showFooter = true,
|
||||||
|
this.enableSearch = false,
|
||||||
|
this.searchableColumns = const [],
|
||||||
|
this.defaultSortColumn,
|
||||||
|
this.defaultSortDescending = false,
|
||||||
|
this.emptyStateBuilder,
|
||||||
|
this.loadingBuilder,
|
||||||
|
this.errorBuilder,
|
||||||
|
this.onRowTap,
|
||||||
|
this.cellPadding = const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
this.headerHeight = 48.0,
|
||||||
|
this.rowHeight,
|
||||||
|
this.dataMode = const DataGridDataMode.all(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Column definitions
|
||||||
|
final List<DataGridColumn<T>> columns;
|
||||||
|
|
||||||
|
/// Per-row actions (shown in action menu)
|
||||||
|
final List<DataGridAction<T>> actions;
|
||||||
|
|
||||||
|
/// Bulk actions (shown when rows selected)
|
||||||
|
final List<DataGridBulkAction<T>> bulkActions;
|
||||||
|
|
||||||
|
/// Enable row selection checkboxes
|
||||||
|
final bool rowsSelectable;
|
||||||
|
|
||||||
|
/// Show header row with column names
|
||||||
|
final bool showHeader;
|
||||||
|
|
||||||
|
/// Show footer with row count/pagination
|
||||||
|
final bool showFooter;
|
||||||
|
|
||||||
|
/// Enable search bar
|
||||||
|
final bool enableSearch;
|
||||||
|
|
||||||
|
/// Column indices to search (empty = all searchable columns)
|
||||||
|
final List<int> searchableColumns;
|
||||||
|
|
||||||
|
/// Default sort column index
|
||||||
|
final int? defaultSortColumn;
|
||||||
|
|
||||||
|
/// Default sort direction
|
||||||
|
final bool defaultSortDescending;
|
||||||
|
|
||||||
|
/// Custom empty state widget
|
||||||
|
final Widget Function(BuildContext)? emptyStateBuilder;
|
||||||
|
|
||||||
|
/// Custom loading widget
|
||||||
|
final Widget Function(BuildContext)? loadingBuilder;
|
||||||
|
|
||||||
|
/// Custom error widget
|
||||||
|
final Widget Function(BuildContext, Object error)? errorBuilder;
|
||||||
|
|
||||||
|
/// Row tap callback
|
||||||
|
final void Function(T item)? onRowTap;
|
||||||
|
|
||||||
|
/// Cell padding
|
||||||
|
final EdgeInsetsGeometry cellPadding;
|
||||||
|
|
||||||
|
/// Header row height
|
||||||
|
final double headerHeight;
|
||||||
|
|
||||||
|
/// Data row height (null = intrinsic)
|
||||||
|
final double? rowHeight;
|
||||||
|
|
||||||
|
/// Data loading mode
|
||||||
|
final DataGridDataMode dataMode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataGridColumn
|
||||||
|
|
||||||
|
Column definition with rendering options.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DataGridColumn<T> {
|
||||||
|
const DataGridColumn({
|
||||||
|
required this.header,
|
||||||
|
required this.valueBuilder,
|
||||||
|
this.cellBuilder,
|
||||||
|
this.cellControlsBuilder,
|
||||||
|
this.width = const DataGridColumnWidth.flex(1),
|
||||||
|
this.alignment = DataGridColumnAlignment.start,
|
||||||
|
this.sortable = false,
|
||||||
|
this.sortField,
|
||||||
|
this.searchable = false,
|
||||||
|
this.visible = true,
|
||||||
|
this.tooltip,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Column header text
|
||||||
|
final String header;
|
||||||
|
|
||||||
|
/// Extract string value from item (for sorting/searching)
|
||||||
|
final String Function(T item) valueBuilder;
|
||||||
|
|
||||||
|
/// Custom cell widget builder (overrides default text)
|
||||||
|
final Widget Function(BuildContext context, T item)? cellBuilder;
|
||||||
|
|
||||||
|
/// Additional controls to show on hover/focus
|
||||||
|
final Widget Function(BuildContext context, T item)? cellControlsBuilder;
|
||||||
|
|
||||||
|
/// Column width specification
|
||||||
|
final DataGridColumnWidth width;
|
||||||
|
|
||||||
|
/// Cell content alignment
|
||||||
|
final DataGridColumnAlignment alignment;
|
||||||
|
|
||||||
|
/// Enable sorting on this column
|
||||||
|
final bool sortable;
|
||||||
|
|
||||||
|
/// API field name for sorting (defaults to column index)
|
||||||
|
final String? sortField;
|
||||||
|
|
||||||
|
/// Include in search
|
||||||
|
final bool searchable;
|
||||||
|
|
||||||
|
/// Column visibility
|
||||||
|
final bool visible;
|
||||||
|
|
||||||
|
/// Tooltip builder for cell
|
||||||
|
final String Function(T item)? tooltip;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataGridColumnWidth
|
||||||
|
|
||||||
|
Column width specification using sealed classes.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
sealed class DataGridColumnWidth {
|
||||||
|
const DataGridColumnWidth._();
|
||||||
|
|
||||||
|
/// Fixed pixel width
|
||||||
|
const factory DataGridColumnWidth.fixed(double width) = _FixedWidth;
|
||||||
|
|
||||||
|
/// Flex factor (like Expanded)
|
||||||
|
const factory DataGridColumnWidth.flex(int flex) = _FlexWidth;
|
||||||
|
|
||||||
|
/// Fraction of available width (0.0 - 1.0)
|
||||||
|
const factory DataGridColumnWidth.fraction(double fraction) = _FractionWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
DataGridColumnWidth.fixed(100) // Always 100px
|
||||||
|
DataGridColumnWidth.flex(2) // 2x flex factor
|
||||||
|
DataGridColumnWidth.fraction(0.3) // 30% of available width
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataGridColumnAlignment
|
||||||
|
|
||||||
|
```dart
|
||||||
|
enum DataGridColumnAlignment {
|
||||||
|
start,
|
||||||
|
center,
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataGridAction
|
||||||
|
|
||||||
|
Per-row action definition.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DataGridAction<T> {
|
||||||
|
const DataGridAction({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.showWhen,
|
||||||
|
this.destructive = false,
|
||||||
|
this.requiresConfirmation = false,
|
||||||
|
this.confirmationMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Action icon
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Action label (shown in menu)
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Action callback
|
||||||
|
final Future<void> Function(T item) onTap;
|
||||||
|
|
||||||
|
/// Conditional visibility
|
||||||
|
final bool Function(T item)? showWhen;
|
||||||
|
|
||||||
|
/// Show in red (destructive action)
|
||||||
|
final bool destructive;
|
||||||
|
|
||||||
|
/// Show confirmation dialog before executing
|
||||||
|
final bool requiresConfirmation;
|
||||||
|
|
||||||
|
/// Custom confirmation message
|
||||||
|
final String? confirmationMessage;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataGridBulkAction
|
||||||
|
|
||||||
|
Action on multiple selected rows.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DataGridBulkAction<T> {
|
||||||
|
const DataGridBulkAction({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.minSelected = 1,
|
||||||
|
this.maxSelected,
|
||||||
|
this.destructive = false,
|
||||||
|
this.requiresConfirmation = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Action icon
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Action label
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Action callback with selected items
|
||||||
|
final Future<void> Function(List<T> items) onTap;
|
||||||
|
|
||||||
|
/// Minimum items required
|
||||||
|
final int minSelected;
|
||||||
|
|
||||||
|
/// Maximum items allowed (null = unlimited)
|
||||||
|
final int? maxSelected;
|
||||||
|
|
||||||
|
/// Show in red
|
||||||
|
final bool destructive;
|
||||||
|
|
||||||
|
/// Show confirmation dialog
|
||||||
|
final bool requiresConfirmation;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataGridDataMode
|
||||||
|
|
||||||
|
Data loading strategy.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
sealed class DataGridDataMode {
|
||||||
|
const DataGridDataMode._();
|
||||||
|
|
||||||
|
/// Load all data at once
|
||||||
|
const factory DataGridDataMode.all() = _AllDataMode;
|
||||||
|
|
||||||
|
/// Traditional pagination
|
||||||
|
const factory DataGridDataMode.paginated({
|
||||||
|
int pageSize,
|
||||||
|
}) = _PaginatedDataMode;
|
||||||
|
|
||||||
|
/// Infinite scroll
|
||||||
|
const factory DataGridDataMode.infinite({
|
||||||
|
int initialLoad,
|
||||||
|
int loadMoreThreshold,
|
||||||
|
}) = _InfiniteDataMode;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Source
|
||||||
|
|
||||||
|
### DataGridSource Interface
|
||||||
|
|
||||||
|
```dart
|
||||||
|
abstract class DataGridSource<T> {
|
||||||
|
/// Fetch data with optional filtering/sorting
|
||||||
|
Future<DataGridResult<T>> fetch({
|
||||||
|
String? searchQuery,
|
||||||
|
String? sortField,
|
||||||
|
bool sortDescending = false,
|
||||||
|
int? offset,
|
||||||
|
int? limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Get total count (for pagination)
|
||||||
|
Future<int> count({String? searchQuery});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataGridResult
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DataGridResult<T> {
|
||||||
|
const DataGridResult({
|
||||||
|
required this.items,
|
||||||
|
required this.totalCount,
|
||||||
|
this.hasMore = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<T> items;
|
||||||
|
final int totalCount;
|
||||||
|
final bool hasMore;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CoreApiDataSource
|
||||||
|
|
||||||
|
Pre-built adapter for Core API endpoints.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CoreApiDataSource<T> extends DataGridSource<T> {
|
||||||
|
CoreApiDataSource({
|
||||||
|
required this.client,
|
||||||
|
required this.endpoint,
|
||||||
|
required this.fromJson,
|
||||||
|
this.searchParam = 'search',
|
||||||
|
this.sortParam = 'sort',
|
||||||
|
this.orderParam = 'order',
|
||||||
|
this.offsetParam = 'offset',
|
||||||
|
this.limitParam = 'limit',
|
||||||
|
});
|
||||||
|
|
||||||
|
final ApiClient client;
|
||||||
|
final String endpoint;
|
||||||
|
final T Function(Map<String, dynamic>) fromJson;
|
||||||
|
final String searchParam;
|
||||||
|
final String sortParam;
|
||||||
|
final String orderParam;
|
||||||
|
final String offsetParam;
|
||||||
|
final String limitParam;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DataGridResult<T>> fetch({
|
||||||
|
String? searchQuery,
|
||||||
|
String? sortField,
|
||||||
|
bool sortDescending = false,
|
||||||
|
int? offset,
|
||||||
|
int? limit,
|
||||||
|
}) async {
|
||||||
|
final params = <String, dynamic>{};
|
||||||
|
|
||||||
|
if (searchQuery != null && searchQuery.isNotEmpty) {
|
||||||
|
params[searchParam] = searchQuery;
|
||||||
|
}
|
||||||
|
if (sortField != null) {
|
||||||
|
params[sortParam] = sortField;
|
||||||
|
params[orderParam] = sortDescending ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
if (offset != null) params[offsetParam] = offset;
|
||||||
|
if (limit != null) params[limitParam] = limit;
|
||||||
|
|
||||||
|
final response = await client.get(endpoint, queryParameters: params);
|
||||||
|
final items = (response.data as List).map((j) => fromJson(j)).toList();
|
||||||
|
|
||||||
|
return DataGridResult(
|
||||||
|
items: items,
|
||||||
|
totalCount: items.length, // Or from response headers
|
||||||
|
hasMore: items.length == limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### DataGridNotifier
|
||||||
|
|
||||||
|
Riverpod notifier for grid state.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class DataGridNotifier<T> extends _$DataGridNotifier<T> {
|
||||||
|
@override
|
||||||
|
DataGridState<T> build(DataGridSource<T> source, DataGridConfig<T> config) {
|
||||||
|
_loadData();
|
||||||
|
return DataGridState.loading();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
state = DataGridState.loading();
|
||||||
|
try {
|
||||||
|
final result = await source.fetch(
|
||||||
|
searchQuery: _searchQuery,
|
||||||
|
sortField: _sortField,
|
||||||
|
sortDescending: _sortDescending,
|
||||||
|
);
|
||||||
|
state = DataGridState.loaded(
|
||||||
|
items: result.items,
|
||||||
|
totalCount: result.totalCount,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
state = DataGridState.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void search(String query) { ... }
|
||||||
|
void sort(String field, bool descending) { ... }
|
||||||
|
void selectRow(T item) { ... }
|
||||||
|
void selectAll() { ... }
|
||||||
|
void clearSelection() { ... }
|
||||||
|
Future<void> refresh() { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataGridState
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@freezed
|
||||||
|
class DataGridState<T> with _$DataGridState<T> {
|
||||||
|
const factory DataGridState.loading() = _Loading;
|
||||||
|
|
||||||
|
const factory DataGridState.loaded({
|
||||||
|
required List<T> items,
|
||||||
|
required int totalCount,
|
||||||
|
@Default({}) Set<T> selectedItems,
|
||||||
|
String? searchQuery,
|
||||||
|
String? sortField,
|
||||||
|
@Default(false) bool sortDescending,
|
||||||
|
}) = _Loaded;
|
||||||
|
|
||||||
|
const factory DataGridState.error(Object error) = _Error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/shared/components/data_grid/
|
||||||
|
├── data_grid.dart # Main widget
|
||||||
|
├── data_grid_config.dart # Configuration classes
|
||||||
|
├── data_grid_column.dart # Column definition
|
||||||
|
├── data_grid_action.dart # Action definitions
|
||||||
|
├── data_grid_source.dart # Data source interface
|
||||||
|
├── data_grid_provider.dart # Riverpod state
|
||||||
|
├── data_grid_state.dart # Freezed state class
|
||||||
|
├── widgets/
|
||||||
|
│ ├── data_grid_header.dart # Header row with sort indicators
|
||||||
|
│ ├── data_grid_row.dart # Data row
|
||||||
|
│ ├── data_grid_cell.dart # Cell wrapper
|
||||||
|
│ ├── data_grid_checkbox.dart # Selection checkbox
|
||||||
|
│ ├── data_grid_actions_menu.dart # Row actions popup
|
||||||
|
│ ├── data_grid_bulk_actions.dart # Bulk action bar
|
||||||
|
│ ├── data_grid_search_bar.dart # Search input
|
||||||
|
│ ├── data_grid_footer.dart # Footer with count/pagination
|
||||||
|
│ └── data_grid_empty_state.dart # Empty state display
|
||||||
|
└── adapters/
|
||||||
|
└── core_api_source.dart # Core API adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic Table
|
||||||
|
|
||||||
|
```dart
|
||||||
|
DataGrid<User>(
|
||||||
|
config: DataGridConfig(
|
||||||
|
columns: [
|
||||||
|
DataGridColumn(header: 'Name', valueBuilder: (u) => u.name),
|
||||||
|
DataGridColumn(header: 'Email', valueBuilder: (u) => u.email),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
source: UsersDataSource(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Actions and Selection
|
||||||
|
|
||||||
|
```dart
|
||||||
|
DataGrid<Container>(
|
||||||
|
config: DataGridConfig(
|
||||||
|
columns: [...],
|
||||||
|
rowsSelectable: true,
|
||||||
|
actions: [
|
||||||
|
DataGridAction(
|
||||||
|
icon: Icons.restart_alt,
|
||||||
|
label: 'Restart',
|
||||||
|
onTap: (c) async => await restartContainer(c.id),
|
||||||
|
requiresConfirmation: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bulkActions: [
|
||||||
|
DataGridBulkAction(
|
||||||
|
icon: Icons.delete,
|
||||||
|
label: 'Delete Selected',
|
||||||
|
onTap: (items) async => await deleteContainers(items),
|
||||||
|
destructive: true,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
source: ContainersDataSource(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Custom Cell Rendering
|
||||||
|
|
||||||
|
```dart
|
||||||
|
DataGridColumn<Device>(
|
||||||
|
header: 'State',
|
||||||
|
valueBuilder: (d) => d.state,
|
||||||
|
cellBuilder: (context, device) => Switch(
|
||||||
|
value: device.state == 'on',
|
||||||
|
onChanged: (v) => toggleDevice(device.entityId, v),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Pagination
|
||||||
|
|
||||||
|
```dart
|
||||||
|
DataGrid<LogEntry>(
|
||||||
|
config: DataGridConfig(
|
||||||
|
columns: [...],
|
||||||
|
dataMode: DataGridDataMode.paginated(pageSize: 50),
|
||||||
|
showFooter: true,
|
||||||
|
),
|
||||||
|
source: LogsDataSource(),
|
||||||
|
)
|
||||||
|
```
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
This document covers deploying Tatlock UI to the Tower of Joy infrastructure.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → NPM (home.schweitz.net:443) → tatlock-ui container (port 8092) → Flutter web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web Build
|
||||||
|
|
||||||
|
### Local Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate version info
|
||||||
|
dart run tool/generate_version.dart
|
||||||
|
|
||||||
|
# Build for web release
|
||||||
|
flutter build web --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Build output: `build/web/`
|
||||||
|
|
||||||
|
### Build Optimizations
|
||||||
|
|
||||||
|
For production builds, consider:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter build web --release \
|
||||||
|
--dart-define=FLUTTER_WEB_USE_SKIA=true \
|
||||||
|
--tree-shake-icons
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
### Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Build stage
|
||||||
|
FROM ghcr.io/cirruslabs/flutter:stable AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependency files
|
||||||
|
COPY pubspec.* ./
|
||||||
|
RUN flutter pub get
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN dart run tool/generate_version.dart
|
||||||
|
RUN flutter build web --release
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built web app
|
||||||
|
COPY --from=build /app/build/web /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx config (optional)
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### nginx.conf (Optional)
|
||||||
|
|
||||||
|
For SPA routing support:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
|
||||||
|
# SPA routing - serve index.html for all routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Don't cache index.html
|
||||||
|
location = /index.html {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Image Locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t tatlock-ui:latest .
|
||||||
|
|
||||||
|
# Test locally
|
||||||
|
docker run -p 8092:80 tatlock-ui:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Portainer Stack
|
||||||
|
|
||||||
|
### docker-compose.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
tatlock-ui:
|
||||||
|
image: git.schweitz.net/jpmschweitzer/tatlock-ui:latest
|
||||||
|
container_name: tatlock-ui
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8092:80"
|
||||||
|
networks:
|
||||||
|
- docker-dataplane
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
docker-dataplane:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy to Portainer
|
||||||
|
|
||||||
|
1. Go to Portainer: https://portainer.schweitz.net
|
||||||
|
2. Navigate to Stacks → Add Stack
|
||||||
|
3. Name: `tatlock-ui`
|
||||||
|
4. Paste docker-compose.yml content
|
||||||
|
5. Deploy
|
||||||
|
|
||||||
|
## Nginx Proxy Manager
|
||||||
|
|
||||||
|
### Proxy Host Configuration
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Domain | `home.schweitz.net` |
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Forward Hostname | `tatlock-ui` (container name) |
|
||||||
|
| Forward Port | `80` |
|
||||||
|
|
||||||
|
### SSL
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| SSL Certificate | Let's Encrypt |
|
||||||
|
| Force SSL | Yes |
|
||||||
|
| HTTP/2 | Yes |
|
||||||
|
| HSTS | Yes |
|
||||||
|
|
||||||
|
### Advanced (Custom Nginx)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# WebSocket support (if needed for future features)
|
||||||
|
location /ws {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
### Gitea Actions Workflow
|
||||||
|
|
||||||
|
Create `.gitea/workflows/build.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/cirruslabs/flutter:stable
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Generate version
|
||||||
|
run: dart run tool/generate_version.dart
|
||||||
|
|
||||||
|
- name: Analyze
|
||||||
|
run: flutter analyze
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: flutter test
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.schweitz.net
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: git.schweitz.net/jpmschweitzer/tatlock-ui:latest
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
|
||||||
|
steps:
|
||||||
|
- name: Trigger Watchtower
|
||||||
|
run: |
|
||||||
|
curl -X POST "http://watchtower:8080/v1/update" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Runtime Configuration
|
||||||
|
|
||||||
|
For environment-specific settings, use compile-time defines:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter build web --release \
|
||||||
|
--dart-define=API_URL=https://api.schweitz.net \
|
||||||
|
--dart-define=AUTH_URL=https://auth.schweitz.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Access in code:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AppConfig {
|
||||||
|
static const apiUrl = String.fromEnvironment(
|
||||||
|
'API_URL',
|
||||||
|
defaultValue: 'https://api.schweitz.net',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const authUrl = String.fromEnvironment(
|
||||||
|
'AUTH_URL',
|
||||||
|
defaultValue: 'https://auth.schweitz.net',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Monitoring
|
||||||
|
|
||||||
|
### Container Health Check
|
||||||
|
|
||||||
|
Add to Dockerfile:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/ || exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uptime Monitoring
|
||||||
|
|
||||||
|
Add to your monitoring solution (Uptime Kuma, etc.):
|
||||||
|
- URL: `https://home.schweitz.net`
|
||||||
|
- Interval: 60s
|
||||||
|
- Expected status: 200
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
### Quick Rollback
|
||||||
|
|
||||||
|
If issues arise after deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In Portainer, update image tag to previous version
|
||||||
|
git.schweitz.net/jpmschweitzer/tatlock-ui:previous-tag
|
||||||
|
|
||||||
|
# Or via CLI
|
||||||
|
docker pull git.schweitz.net/jpmschweitzer/tatlock-ui:v0.1.0
|
||||||
|
docker stop tatlock-ui
|
||||||
|
docker rm tatlock-ui
|
||||||
|
docker run -d --name tatlock-ui -p 8092:80 git.schweitz.net/jpmschweitzer/tatlock-ui:v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container won't start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker logs tatlock-ui
|
||||||
|
|
||||||
|
# Common issues:
|
||||||
|
# - Port 8092 already in use
|
||||||
|
# - Network not found (create docker-dataplane network)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 502 Bad Gateway in NPM
|
||||||
|
|
||||||
|
1. Verify container is running: `docker ps | grep tatlock-ui`
|
||||||
|
2. Check container is on correct network
|
||||||
|
3. Verify port mapping in docker-compose
|
||||||
|
|
||||||
|
### Assets not loading
|
||||||
|
|
||||||
|
Check nginx is serving from correct path and CORS headers if loading from different domain.
|
||||||
+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,
|
||||||
|
);
|
||||||
|
```
|
||||||
+1
-1
@@ -23,7 +23,7 @@ class TatlockApp extends StatelessWidget {
|
|||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Tatlock UI',
|
title: 'Tatlock UI',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
home: const HomePage(),
|
home: const HomePage(),
|
||||||
|
|||||||
Reference in New Issue
Block a user