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:
2025-12-30 16:26:44 +01:00
co-authored by Claude Opus 4.5
parent b4ec857a8f
commit 0c3239f4b0
6 changed files with 2125 additions and 1 deletions
+424
View File
@@ -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
+329
View File
@@ -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
```
+599
View File
@@ -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(),
)
```
+328
View File
@@ -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
View File
@@ -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
View File
@@ -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(),