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(
|
||||
title: 'Tatlock UI',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomePage(),
|
||||
|
||||
Reference in New Issue
Block a user