- 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>
726 lines
22 KiB
Markdown
726 lines
22 KiB
Markdown
# 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<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
|
|
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:
|
|
```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();
|
|
}
|
|
}
|
|
```
|
|
|
|
## 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<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:
|
|
|
|
```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<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:**
|
|
|
|
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<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:
|
|
```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));
|
|
}
|
|
```
|
|
|
|
### 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<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
|
|
|
|
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<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:
|
|
|
|
```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<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**:
|
|
```dart
|
|
DropdownButton<String>(
|
|
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` |
|