- Decentralized Room Registry pattern - Each room registers itself with central registry - Dynamic navigation tabs and settings dropdown - New Media Room and Parlor feature folders - Permission-based room filtering support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
22 KiB
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).
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 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:
// 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:
// 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):
// 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:
// 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<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:
// 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:
// 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();
}
}
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:
@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<String, dynamic> 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:
@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<String, dynamic> 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:
-
Always use generated
toJson()- Never write custom JSON methods that selectively include fields. The generatedtoJson()from freezed/json_serializable always includes ALL fields, which is the correct behavior. -
Never create custom
toCreateJson()or similar - This leads to bugs where fields are silently dropped. -
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:
// ✅ 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
// Create - convert entity to model, use toJson()
Future<QuickLink> createQuickLink(QuickLink link) async {
final model = QuickLinkModel.fromEntity(link);
final response = await _dio.post<Map<String, dynamic>>(
_basePath,
data: model.toJson(), // Always use generated toJson()
);
return QuickLinkModel.fromJson(response.data!).toEntity();
}
// Update - same pattern
Future<QuickLink> updateQuickLink(QuickLink link) async {
final model = QuickLinkModel.fromEntity(link);
final response = await _dio.put<Map<String, dynamic>>(
'$_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:
// 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:
// 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:
// 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:
// 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:
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:
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.
// ✅ Correct - Freezed 3.x
@freezed
sealed class UserModel with _$UserModel {
const factory UserModel({
required String id,
required String name,
}) = _UserModel;
factory UserModel.fromJson(Map<String, dynamic> 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
- Never import from
data/indomain/ - Never import from
presentation/indomain/ordata/ - Import entities from
domain/, not models fromdata/ - Use relative imports within a feature, package imports across features
// 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
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
- Central ID Registry - All IDs defined in
lib/core/semantics/semantic_ids.dart - Naming Convention -
{area}_{component}_{identifier}(e.g.,dataGrid_row_abc123) - Web Enabled - Semantics tree exposed via
SemanticsBinding.instance.ensureSemantics()inmain.dart
For complete documentation on semantic patterns, automation queries, and best practices, see 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:
// 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<RouteBase>
requiredPermissions: [], // Empty = accessible to all
);
/// Register with the central registry.
void registerControlRoom() {
roomRegistry.register(controlRoomRoom);
}
/// Routes for go_router.
List<RouteBase> controlRoomRoutes() {
return [
GoRoute(path: '/control-room', ...),
// Sub-routes...
];
}
Registration Order
Rooms are registered in app_router.dart in display order:
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):
List<RoomDefinition> 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:
DropdownButton<String>(
items: roomRegistry.all
.map((room) => DropdownMenuItem(
value: room.id,
child: Text(room.label),
))
.toList(),
);
Router - All routes from registry:
ShellRoute(
routes: [
...roomRegistry.allRoutes(),
// Plus non-room routes like /settings
],
);
Route matching:
int _selectedIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
return roomRegistry.indexOfRoute(location);
}
Permission Filtering
Rooms can specify required permissions:
final adminRoom = RoomDefinition(
id: 'admin',
requiredPermissions: ['admin:access'],
// ...
);
// Filter by user permissions
final accessibleRooms = roomRegistry.accessibleTo(userPermissions);
Adding a New Room
- Create feature folder:
lib/features/{room_name}/ - Create
router.dartwithRoomDefinitionand register function - Create placeholder page in
presentation/pages/{room_name}_page.dart - Add
register{RoomName}()call to_initializeRoomRegistry()inapp_router.dart - 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 |