diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md new file mode 100644 index 0000000..3d1cfc5 --- /dev/null +++ b/docs/API_INTEGRATION.md @@ -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 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e5dcb5d --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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> getContainers(); + Future getContainer(String id); + Future startContainer(String id); + Future 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 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 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> 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> 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> build() async { + final repository = ref.watch(containerRepositoryProvider); + return repository.getContainers(); + } + + Future 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 +``` diff --git a/docs/DATAGRID.md b/docs/DATAGRID.md new file mode 100644 index 0000000..dde6be6 --- /dev/null +++ b/docs/DATAGRID.md @@ -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( + 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 { + 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> columns; + + /// Per-row actions (shown in action menu) + final List> actions; + + /// Bulk actions (shown when rows selected) + final List> 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 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 { + 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 { + 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 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 { + 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 Function(List 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 { + /// Fetch data with optional filtering/sorting + Future> fetch({ + String? searchQuery, + String? sortField, + bool sortDescending = false, + int? offset, + int? limit, + }); + + /// Get total count (for pagination) + Future count({String? searchQuery}); +} +``` + +### DataGridResult + +```dart +class DataGridResult { + const DataGridResult({ + required this.items, + required this.totalCount, + this.hasMore = false, + }); + + final List items; + final int totalCount; + final bool hasMore; +} +``` + +### CoreApiDataSource + +Pre-built adapter for Core API endpoints. + +```dart +class CoreApiDataSource extends DataGridSource { + 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) fromJson; + final String searchParam; + final String sortParam; + final String orderParam; + final String offsetParam; + final String limitParam; + + @override + Future> fetch({ + String? searchQuery, + String? sortField, + bool sortDescending = false, + int? offset, + int? limit, + }) async { + final params = {}; + + 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 extends _$DataGridNotifier { + @override + DataGridState build(DataGridSource source, DataGridConfig config) { + _loadData(); + return DataGridState.loading(); + } + + Future _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 refresh() { ... } +} +``` + +### DataGridState + +```dart +@freezed +class DataGridState with _$DataGridState { + const factory DataGridState.loading() = _Loading; + + const factory DataGridState.loaded({ + required List items, + required int totalCount, + @Default({}) Set 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( + 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( + 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( + header: 'State', + valueBuilder: (d) => d.state, + cellBuilder: (context, device) => Switch( + value: device.state == 'on', + onChanged: (v) => toggleDevice(device.entityId, v), + ), +) +``` + +### With Pagination + +```dart +DataGrid( + config: DataGridConfig( + columns: [...], + dataMode: DataGridDataMode.paginated(pageSize: 50), + showFooter: true, + ), + source: LogsDataSource(), +) +``` diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..32d6dce --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -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. diff --git a/docs/THEMING.md b/docs/THEMING.md new file mode 100644 index 0000000..9b9f283 --- /dev/null +++ b/docs/THEMING.md @@ -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 _loadSavedSetting() async { + final prefs = await SharedPreferences.getInstance(); + final value = prefs.getString(_key); + if (value != null) { + state = ThemeSetting.values.byName(value); + } + } + + Future 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, +); +``` diff --git a/lib/main.dart b/lib/main.dart index 7d5c892..29a41fa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(),