# 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 The directory structure mirrors the "Rooms of the Estate" UI navigation (see [UI_LAYOUT.md](./UI_LAYOUT.md)). ``` 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 │ ├── semantics/ # Semantic IDs for automation │ └── theme/ # Material 3 theming ├── routing/ # go_router configuration ├── shared/ # Reusable components │ ├── components/ # DataGrid, common widgets │ └── layouts/ # App scaffold, navigation ├── features/ # Feature modules (rooms) │ ├── front_hall/ # Dashboard - estate overview │ │ └── router.dart # Room registration │ ├── control_room/ # Infrastructure │ │ ├── router.dart # Room registration │ │ ├── containers/ # Container management │ │ └── npm/ # Proxy hosts management │ ├── security/ # User & access management │ │ ├── router.dart # Room registration │ │ ├── users/ # User management │ │ └── groups/ # Group management │ ├── parlor/ # AI chat & automation hub │ │ └── router.dart # Room registration │ ├── media_room/ # Media management (future) │ │ └── router.dart # Room registration │ └── settings/ # User preferences └── chat/ # Tatlock chat - omnipresent, NOT a room ``` ### Room-to-Directory Mapping | UI Room | Directory | Purpose | |---------|-----------|---------| | Front Hall | `features/front_hall/` | Dashboard, overview, quick access | | Control Room | `features/control_room/` | Infrastructure management | | Security | `features/security/` | User & access management | | Parlor | `features/parlor/` | AI chat & automation hub | | Media Room | `features/media_room/` | Media management (future) | | *(non-room)* | `features/settings/` | User preferences | | *(omnipresent)* | `chat/` | Tatlock assistant dock | Note: `chat/` lives at the top level of `lib/` (not under `features/`) because it's not a navigable room - it's an omnipresent dock injected at the layout level. Each room has a `router.dart` file that registers the room with the central registry. See [Room Registry Pattern](#room-registry-pattern) for details. ## 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 sealed 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(); } } ``` ## Data Model Patterns Models handle conversion between API JSON and domain entities. The pattern depends on whether the feature is **read-only** or **CRUD**. ### Read-Only Models For data fetched from external systems (Docker, NPM, Portainer) where Flutter doesn't create/update records: ```dart @freezed sealed class ContainerModel with _$ContainerModel { const factory ContainerModel({ required String id, required String name, required String status, }) = _ContainerModel; const ContainerModel._(); factory ContainerModel.fromJson(Map json) => _$ContainerModelFromJson(json); /// Converts API response to domain entity. Container toEntity() => Container( id: id, name: name, status: ContainerStatus.values.byName(status), ); } ``` **Only `toEntity()` is needed** - no `fromEntity()` or `toJson()` required. ### CRUD Models (Bidirectional) For data that Flutter creates, updates, and deletes: ```dart @freezed sealed class QuickLinkModel with _$QuickLinkModel { const factory QuickLinkModel({ @Default(0) int id, required String title, required String url, String? icon, String? category, @Default(0) int position, @JsonKey(name: 'is_visible') @Default(true) bool isVisible, }) = _QuickLinkModel; const QuickLinkModel._(); factory QuickLinkModel.fromJson(Map json) => _$QuickLinkModelFromJson(json); /// Converts API response to domain entity. QuickLink toEntity() => QuickLink( id: id.toString(), name: title, url: url, iconName: icon ?? 'link', category: category, sortOrder: position, isActive: isVisible, ); /// Creates model from domain entity for API requests. factory QuickLinkModel.fromEntity(QuickLink entity) => QuickLinkModel( id: int.tryParse(entity.id) ?? 0, title: entity.name, url: entity.url, icon: entity.iconName, category: entity.category, position: entity.sortOrder, isVisible: entity.isActive, ); } ``` **Critical rules for CRUD models:** 1. **Always use generated `toJson()`** - Never write custom JSON methods that selectively include fields. The generated `toJson()` from freezed/json_serializable always includes ALL fields, which is the correct behavior. 2. **Never create custom `toCreateJson()` or similar** - This leads to bugs where fields are silently dropped. 3. **Use `fromEntity()` factory** - Maps domain entity fields to API field names. ### Form Update Pattern When updating existing entities in forms, **always use `copyWith()`** to preserve existing data: ```dart // ✅ Correct - preserves all existing fields final updatedLink = existingLink.copyWith( name: _nameController.text.trim(), url: _urlController.text.trim(), category: category.isEmpty ? null : category, ); await actions.update(updatedLink); // ❌ Wrong - loses existing data not in form final newLink = QuickLink( id: existingLink.id, name: _nameController.text.trim(), url: _urlController.text.trim(), // Missing: sortOrder, other fields... ); ``` ### Datasource Usage ```dart // Create - convert entity to model, use toJson() Future createQuickLink(QuickLink link) async { final model = QuickLinkModel.fromEntity(link); final response = await _dio.post>( _basePath, data: model.toJson(), // Always use generated toJson() ); return QuickLinkModel.fromJson(response.data!).toEntity(); } // Update - same pattern Future updateQuickLink(QuickLink link) async { final model = QuickLinkModel.fromEntity(link); final response = await _dio.put>( '$_basePath/${link.id}', data: model.toJson(), // Always use generated toJson() ); return QuickLinkModel.fromJson(response.data!).toEntity(); } ``` ### 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)); } ``` ### Persistent Providers By default, `@riverpod` generates providers with `isAutoDispose: true`, meaning they dispose when no longer watched. This causes issues for: - **API clients** with interceptors that store a `Ref` - **App-level state** like theme, auth, config - **Providers with listeners** to other providers Use `@persistentRiverpod` from `core/providers/annotations.dart` for these cases: ```dart import 'package:tatlock_ui/core/providers/annotations.dart'; // ✅ Correct - persists for app lifetime @persistentRiverpod Dio coreApiClient(Ref ref) { ... } @persistentRiverpod class ThemeNotifier extends _$ThemeNotifier { ... } // ❌ Wrong - auto-dispose can invalidate stored Ref @riverpod Dio coreApiClient(Ref ref) { ... } ``` **When to use `@persistentRiverpod`:** | Use Case | Annotation | Example | |----------|------------|---------| | API clients with interceptors | `@persistentRiverpod` | `coreApiClient`, `tatlockApiClient` | | Auth state provider | `@persistentRiverpod` | `AuthNotifier` | | Theme/config providers | `@persistentRiverpod` | `ThemeNotifier` | | Feature data providers | `@riverpod` (default) | `ContainersNotifier` | | UI state providers | `@riverpod` (default) | `SearchFilterNotifier` | ## 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 ### Freezed 3.x: Required `sealed class` **Freezed 3.x requires the `sealed` keyword** on all classes with generated mixins. Without it, the generated code will fail to compile with errors about missing concrete implementations. ```dart // ✅ Correct - Freezed 3.x @freezed sealed class UserModel with _$UserModel { const factory UserModel({ required String id, required String name, }) = _UserModel; factory UserModel.fromJson(Map json) => _$UserModelFromJson(json); } // ❌ Wrong - will fail to compile @freezed class UserModel with _$UserModel { // Missing `sealed` const factory UserModel({...}) = _UserModel; } ``` The `sealed` keyword was introduced in Dart 3.0 and allows the generated mixin `_$UserModel` to have abstract members that are implemented by the private `_UserModel` class. **Always use `sealed class` with `@freezed`** - this applies to all models, entities, and state classes using Freezed. ## 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 ``` ## Semantic Identifiers for Automation All interactive widgets should have semantic identifiers for UI automation. This enables reliable testing with Puppeteer, Appium, and other automation tools. ### Quick Reference ```dart import 'package:tatlock_ui/core/semantics/semantic_ids.dart'; // Wrap interactive widgets with Semantics Semantics( identifier: DataGridSemantics.row(item.id), label: 'Select ${item.name}', child: MyRowWidget(item: item), ) ``` ### Key Points 1. **Central ID Registry** - All IDs defined in `lib/core/semantics/semantic_ids.dart` 2. **Naming Convention** - `{area}_{component}_{identifier}` (e.g., `dataGrid_row_abc123`) 3. **Web Enabled** - Semantics tree exposed via `SemanticsBinding.instance.ensureSemantics()` in `main.dart` For complete documentation on semantic patterns, automation queries, and best practices, see **[TESTING.md](./TESTING.md)**. ## Room Registry Pattern The application uses a **decentralized room registry** pattern for navigation. Each feature/room registers itself with the central registry, providing: - **Decoupled navigation** - Rooms define their own routes, icons, and metadata - **Permission-based filtering** - Rooms can specify required permissions - **Dynamic UI** - Settings dropdowns and tab bars build from registry - **Single source of truth** - All room metadata in one place per room ### Architecture ``` lib/routing/room_registry.dart # Central registry class lib/features/{room}/router.dart # Per-room registration ``` ### Room Definition Each room's `router.dart` exports a `RoomDefinition` and register function: ```dart // lib/features/control_room/router.dart import 'package:tatlock_ui/routing/room_registry.dart'; /// Room definition with all metadata. final controlRoomRoom = RoomDefinition( id: 'control-room', // Preference value, URL segment label: 'Control Room', // Display name icon: Icons.dns_outlined, // Unselected icon selectedIcon: Icons.dns, // Selected icon defaultRoute: '/control-room/containers', // Landing route routes: controlRoomRoutes, // Function returning List requiredPermissions: [], // Empty = accessible to all ); /// Register with the central registry. void registerControlRoom() { roomRegistry.register(controlRoomRoom); } /// Routes for go_router. List controlRoomRoutes() { return [ GoRoute(path: '/control-room', ...), // Sub-routes... ]; } ``` ### Registration Order Rooms are registered in `app_router.dart` in display order: ```dart void _initializeRoomRegistry() { if (roomRegistry.all.isNotEmpty) return; // Skip if initialized // Registration order = tab order registerFrontHall(); registerControlRoom(); registerSecurity(); registerParlor(); registerMediaRoom(); } ``` ### Using the Registry **Navigation tabs** (`top_header_bar.dart`): ```dart List get _rooms => roomRegistry.all; // Build tab for each room for (final room in _rooms) { IconButton( icon: Icon(isSelected ? room.selectedIcon : room.icon), onPressed: () => onRoomSelected(index), ); } ``` **Settings dropdown**: ```dart DropdownButton( items: roomRegistry.all .map((room) => DropdownMenuItem( value: room.id, child: Text(room.label), )) .toList(), ); ``` **Router** - All routes from registry: ```dart ShellRoute( routes: [ ...roomRegistry.allRoutes(), // Plus non-room routes like /settings ], ); ``` **Route matching**: ```dart int _selectedIndex(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; return roomRegistry.indexOfRoute(location); } ``` ### Permission Filtering Rooms can specify required permissions: ```dart final adminRoom = RoomDefinition( id: 'admin', requiredPermissions: ['admin:access'], // ... ); // Filter by user permissions final accessibleRooms = roomRegistry.accessibleTo(userPermissions); ``` ### Adding a New Room 1. Create feature folder: `lib/features/{room_name}/` 2. Create `router.dart` with `RoomDefinition` and register function 3. Create placeholder page in `presentation/pages/{room_name}_page.dart` 4. Add `register{RoomName}()` call to `_initializeRoomRegistry()` in `app_router.dart` 5. Import the router in `app_router.dart` ### Current Rooms | Room | ID | Default Route | |------|-----|---------------| | Front Hall | `front-hall` | `/front-hall` | | Control Room | `control-room` | `/control-room/containers` | | Security | `security` | `/security/users` | | Parlor | `parlor` | `/parlor` | | Media Room | `media-room` | `/media-room` |