chore: release v1.4.0
- 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>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
40ba6a869d
commit
c3f6d27a52
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-01-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Decentralized Room Registry** - Each room registers itself with central registry
|
||||||
|
- `RoomDefinition` class with id, label, icons, routes, and permissions
|
||||||
|
- `RoomRegistry` singleton for managing all rooms
|
||||||
|
- Dynamic navigation tabs built from registry
|
||||||
|
- Settings dropdown builds from available rooms
|
||||||
|
- Permission-based room filtering support
|
||||||
|
- **Media Room** - New placeholder room for future media management features
|
||||||
|
- **Parlor** - Now a proper feature folder with router registration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Room navigation is now fully dynamic via registry
|
||||||
|
- `top_header_bar.dart` uses `roomRegistry.all` instead of hardcoded list
|
||||||
|
- `app_scaffold.dart` uses registry for route matching and navigation
|
||||||
|
- Settings page dropdown populated from `roomRegistry.all`
|
||||||
|
- Routers moved to per-room pattern:
|
||||||
|
- `lib/features/front_hall/router.dart` (new)
|
||||||
|
- `lib/features/parlor/router.dart` (new)
|
||||||
|
- `lib/features/media_room/router.dart` (new)
|
||||||
|
- Existing `control_room/router.dart` and `security/router.dart` now register with registry
|
||||||
|
- Documentation updated in ARCHITECTURE.md with Room Registry Pattern section
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Hardcoded room lists in `top_header_bar.dart` and `app_scaffold.dart`
|
||||||
|
- `_PlaceholderPage` widget in `app_router.dart` (each room has its own page)
|
||||||
|
|
||||||
## [1.3.1] - 2026-01-05
|
## [1.3.1] - 2026-01-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+162
-9
@@ -28,14 +28,20 @@ lib/
|
|||||||
│ └── layouts/ # App scaffold, navigation
|
│ └── layouts/ # App scaffold, navigation
|
||||||
├── features/ # Feature modules (rooms)
|
├── features/ # Feature modules (rooms)
|
||||||
│ ├── front_hall/ # Dashboard - estate overview
|
│ ├── front_hall/ # Dashboard - estate overview
|
||||||
|
│ │ └── router.dart # Room registration
|
||||||
│ ├── control_room/ # Infrastructure
|
│ ├── control_room/ # Infrastructure
|
||||||
|
│ │ ├── router.dart # Room registration
|
||||||
│ │ ├── containers/ # Container management
|
│ │ ├── containers/ # Container management
|
||||||
│ │ ├── stacks/ # Stack management
|
│ │ └── npm/ # Proxy hosts management
|
||||||
│ │ ├── networks/ # Network management
|
│ ├── security/ # User & access management
|
||||||
│ │ └── volumes/ # Volume management
|
│ │ ├── router.dart # Room registration
|
||||||
│ ├── parlor/ # Housekeeping - home automation
|
│ │ ├── users/ # User management
|
||||||
│ ├── library/ # Knowledge management (future)
|
│ │ └── groups/ # Group management
|
||||||
│ └── study/ # Secretarial tasks (future)
|
│ ├── 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
|
└── chat/ # Tatlock chat - omnipresent, NOT a room
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -45,13 +51,16 @@ lib/
|
|||||||
|---------|-----------|---------|
|
|---------|-----------|---------|
|
||||||
| Front Hall | `features/front_hall/` | Dashboard, overview, quick access |
|
| Front Hall | `features/front_hall/` | Dashboard, overview, quick access |
|
||||||
| Control Room | `features/control_room/` | Infrastructure management |
|
| Control Room | `features/control_room/` | Infrastructure management |
|
||||||
| Parlor | `features/parlor/` | Home automation |
|
| Security | `features/security/` | User & access management |
|
||||||
| Library | `features/library/` | Knowledge, docs, bookmarks |
|
| Parlor | `features/parlor/` | AI chat & automation hub |
|
||||||
| Study | `features/study/` | Email, calendar (hidden for now) |
|
| Media Room | `features/media_room/` | Media management (future) |
|
||||||
|
| *(non-room)* | `features/settings/` | User preferences |
|
||||||
| *(omnipresent)* | `chat/` | Tatlock assistant dock |
|
| *(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.
|
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
|
## Feature Structure
|
||||||
|
|
||||||
Each feature follows a three-layer architecture:
|
Each feature follows a three-layer architecture:
|
||||||
@@ -570,3 +579,147 @@ Semantics(
|
|||||||
3. **Web Enabled** - Semantics tree exposed via `SemanticsBinding.instance.ensureSemantics()` in `main.dart`
|
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)**.
|
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` |
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:tatlock_ui/features/control_room/presentation/pages/control_room_page.dart';
|
import 'package:tatlock_ui/features/control_room/presentation/pages/control_room_page.dart';
|
||||||
import 'package:tatlock_ui/routing/app_router.dart';
|
import 'package:tatlock_ui/routing/app_router.dart';
|
||||||
|
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||||
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
|
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
|
||||||
|
|
||||||
/// Route paths for Control Room.
|
/// Route paths for Control Room.
|
||||||
@@ -49,6 +50,22 @@ enum ControlRoomNav {
|
|||||||
/// Get route path for a nav item.
|
/// Get route path for a nav item.
|
||||||
String pathForNav(ControlRoomNav nav) => nav.path;
|
String pathForNav(ControlRoomNav nav) => nav.path;
|
||||||
|
|
||||||
|
/// Control Room room definition.
|
||||||
|
final controlRoomRoom = RoomDefinition(
|
||||||
|
id: 'control-room',
|
||||||
|
label: 'Control Room',
|
||||||
|
icon: Icons.dns_outlined,
|
||||||
|
selectedIcon: Icons.dns,
|
||||||
|
defaultRoute: ControlRoomRoutes.containers,
|
||||||
|
routes: controlRoomRoutes,
|
||||||
|
// No permissions required - accessible to all authenticated users
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Register Control Room with the room registry.
|
||||||
|
void registerControlRoom() {
|
||||||
|
roomRegistry.register(controlRoomRoom);
|
||||||
|
}
|
||||||
|
|
||||||
/// Control Room routes for go_router.
|
/// Control Room routes for go_router.
|
||||||
List<RouteBase> controlRoomRoutes() {
|
List<RouteBase> controlRoomRoutes() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
|
||||||
|
import 'package:tatlock_ui/routing/app_router.dart';
|
||||||
|
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||||
|
|
||||||
|
/// Route paths for Front Hall.
|
||||||
|
abstract class FrontHallRoutes {
|
||||||
|
static const base = '/front-hall';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Front Hall room definition.
|
||||||
|
final frontHallRoom = RoomDefinition(
|
||||||
|
id: 'front-hall',
|
||||||
|
label: 'Front Hall',
|
||||||
|
icon: Icons.door_front_door_outlined,
|
||||||
|
selectedIcon: Icons.door_front_door,
|
||||||
|
defaultRoute: FrontHallRoutes.base,
|
||||||
|
routes: frontHallRoutes,
|
||||||
|
// No permissions required - accessible to all authenticated users
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Register Front Hall with the room registry.
|
||||||
|
void registerFrontHall() {
|
||||||
|
roomRegistry.register(frontHallRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Front Hall routes for go_router.
|
||||||
|
List<RouteBase> frontHallRoutes() {
|
||||||
|
return [
|
||||||
|
GoRoute(
|
||||||
|
path: FrontHallRoutes.base,
|
||||||
|
name: 'frontHall',
|
||||||
|
pageBuilder: (context, state) =>
|
||||||
|
noTransitionPage(context, state, const FrontHallPage()),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Media Room - media management and playback.
|
||||||
|
///
|
||||||
|
/// Placeholder page for future implementation.
|
||||||
|
class MediaRoomPage extends StatelessWidget {
|
||||||
|
const MediaRoomPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.movie,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Media Room',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Media management and playback - coming soon',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:tatlock_ui/features/media_room/presentation/pages/media_room_page.dart';
|
||||||
|
import 'package:tatlock_ui/routing/app_router.dart';
|
||||||
|
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||||
|
|
||||||
|
/// Route paths for Media Room.
|
||||||
|
abstract class MediaRoomRoutes {
|
||||||
|
static const base = '/media-room';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Media Room room definition.
|
||||||
|
final mediaRoomRoom = RoomDefinition(
|
||||||
|
id: 'media-room',
|
||||||
|
label: 'Media Room',
|
||||||
|
icon: Icons.movie_outlined,
|
||||||
|
selectedIcon: Icons.movie,
|
||||||
|
defaultRoute: MediaRoomRoutes.base,
|
||||||
|
routes: mediaRoomRoutes,
|
||||||
|
// No permissions required - accessible to all authenticated users
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Register Media Room with the room registry.
|
||||||
|
void registerMediaRoom() {
|
||||||
|
roomRegistry.register(mediaRoomRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Media Room routes for go_router.
|
||||||
|
List<RouteBase> mediaRoomRoutes() {
|
||||||
|
return [
|
||||||
|
GoRoute(
|
||||||
|
path: MediaRoomRoutes.base,
|
||||||
|
name: 'mediaRoom',
|
||||||
|
pageBuilder: (context, state) =>
|
||||||
|
noTransitionPage(context, state, const MediaRoomPage()),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Parlor - AI chat and automation hub.
|
||||||
|
///
|
||||||
|
/// Placeholder page for future implementation.
|
||||||
|
class ParlorPage extends StatelessWidget {
|
||||||
|
const ParlorPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.lightbulb,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Parlor',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'AI chat and automation hub - coming soon',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:tatlock_ui/features/parlor/presentation/pages/parlor_page.dart';
|
||||||
|
import 'package:tatlock_ui/routing/app_router.dart';
|
||||||
|
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||||
|
|
||||||
|
/// Route paths for Parlor.
|
||||||
|
abstract class ParlorRoutes {
|
||||||
|
static const base = '/parlor';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parlor room definition.
|
||||||
|
final parlorRoom = RoomDefinition(
|
||||||
|
id: 'parlor',
|
||||||
|
label: 'Parlor',
|
||||||
|
icon: Icons.lightbulb_outline,
|
||||||
|
selectedIcon: Icons.lightbulb,
|
||||||
|
defaultRoute: ParlorRoutes.base,
|
||||||
|
routes: parlorRoutes,
|
||||||
|
// No permissions required - accessible to all authenticated users
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Register Parlor with the room registry.
|
||||||
|
void registerParlor() {
|
||||||
|
roomRegistry.register(parlorRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parlor routes for go_router.
|
||||||
|
List<RouteBase> parlorRoutes() {
|
||||||
|
return [
|
||||||
|
GoRoute(
|
||||||
|
path: ParlorRoutes.base,
|
||||||
|
name: 'parlor',
|
||||||
|
pageBuilder: (context, state) =>
|
||||||
|
noTransitionPage(context, state, const ParlorPage()),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:tatlock_ui/features/security/presentation/pages/security_page.dart';
|
import 'package:tatlock_ui/features/security/presentation/pages/security_page.dart';
|
||||||
import 'package:tatlock_ui/routing/app_router.dart';
|
import 'package:tatlock_ui/routing/app_router.dart';
|
||||||
|
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||||
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
|
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
|
||||||
|
|
||||||
/// Route paths for Security room.
|
/// Route paths for Security room.
|
||||||
@@ -47,6 +48,22 @@ enum SecurityNav {
|
|||||||
/// Get route path for a nav item.
|
/// Get route path for a nav item.
|
||||||
String pathForSecurityNav(SecurityNav nav) => nav.path;
|
String pathForSecurityNav(SecurityNav nav) => nav.path;
|
||||||
|
|
||||||
|
/// Security room definition.
|
||||||
|
final securityRoom = RoomDefinition(
|
||||||
|
id: 'security',
|
||||||
|
label: 'Security',
|
||||||
|
icon: Icons.security_outlined,
|
||||||
|
selectedIcon: Icons.security,
|
||||||
|
defaultRoute: SecurityRoutes.users,
|
||||||
|
routes: securityRoutes,
|
||||||
|
// No permissions required - accessible to all authenticated users
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Register Security room with the room registry.
|
||||||
|
void registerSecurity() {
|
||||||
|
roomRegistry.register(securityRoom);
|
||||||
|
}
|
||||||
|
|
||||||
/// Security room routes for go_router.
|
/// Security room routes for go_router.
|
||||||
List<RouteBase> securityRoutes() {
|
List<RouteBase> securityRoutes() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
||||||
import 'package:tatlock_ui/core/theme/theme_provider.dart';
|
import 'package:tatlock_ui/core/theme/theme_provider.dart';
|
||||||
|
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||||
|
|
||||||
/// Settings page with user preferences.
|
/// Settings page with user preferences.
|
||||||
class SettingsPage extends ConsumerWidget {
|
class SettingsPage extends ConsumerWidget {
|
||||||
@@ -129,20 +130,14 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
_updateDefaultRoom(ref, value);
|
_updateDefaultRoom(ref, value);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
items: const [
|
items: roomRegistry.all
|
||||||
DropdownMenuItem(
|
.map(
|
||||||
value: 'front-hall',
|
(room) => DropdownMenuItem(
|
||||||
child: Text('Front Hall'),
|
value: room.id,
|
||||||
|
child: Text(room.label),
|
||||||
),
|
),
|
||||||
DropdownMenuItem(
|
)
|
||||||
value: 'control-room',
|
.toList(),
|
||||||
child: Text('Control Room'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'parlor',
|
|
||||||
child: Text('Parlor'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+36
-58
@@ -3,13 +3,32 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
||||||
import 'package:tatlock_ui/features/control_room/router.dart';
|
import 'package:tatlock_ui/features/control_room/router.dart';
|
||||||
import 'package:tatlock_ui/features/front_hall/presentation/pages/front_hall_page.dart';
|
import 'package:tatlock_ui/features/front_hall/router.dart';
|
||||||
|
import 'package:tatlock_ui/features/media_room/router.dart';
|
||||||
|
import 'package:tatlock_ui/features/parlor/router.dart';
|
||||||
import 'package:tatlock_ui/features/security/router.dart';
|
import 'package:tatlock_ui/features/security/router.dart';
|
||||||
import 'package:tatlock_ui/features/settings/presentation/pages/settings_page.dart';
|
import 'package:tatlock_ui/features/settings/presentation/pages/settings_page.dart';
|
||||||
|
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||||
import 'package:tatlock_ui/shared/layouts/app_scaffold.dart';
|
import 'package:tatlock_ui/shared/layouts/app_scaffold.dart';
|
||||||
|
|
||||||
part 'app_router.g.dart';
|
part 'app_router.g.dart';
|
||||||
|
|
||||||
|
/// Initialize room registry with all rooms.
|
||||||
|
///
|
||||||
|
/// Called once before creating the router.
|
||||||
|
/// Order of registration determines tab order in UI.
|
||||||
|
void _initializeRoomRegistry() {
|
||||||
|
// Skip if already initialized
|
||||||
|
if (roomRegistry.all.isNotEmpty) return;
|
||||||
|
|
||||||
|
// Register rooms in display order
|
||||||
|
registerFrontHall();
|
||||||
|
registerControlRoom();
|
||||||
|
registerSecurity();
|
||||||
|
registerParlor();
|
||||||
|
registerMediaRoom();
|
||||||
|
}
|
||||||
|
|
||||||
/// No-animation page builder for instant transitions
|
/// No-animation page builder for instant transitions
|
||||||
Page<void> noTransitionPage(BuildContext context, GoRouterState state, Widget child) {
|
Page<void> noTransitionPage(BuildContext context, GoRouterState state, Widget child) {
|
||||||
return NoTransitionPage<void>(
|
return NoTransitionPage<void>(
|
||||||
@@ -21,24 +40,28 @@ Page<void> noTransitionPage(BuildContext context, GoRouterState state, Widget ch
|
|||||||
/// Route paths as constants.
|
/// Route paths as constants.
|
||||||
abstract class AppRoutes {
|
abstract class AppRoutes {
|
||||||
static const root = '/';
|
static const root = '/';
|
||||||
static const frontHall = '/front-hall';
|
|
||||||
static const parlor = '/parlor';
|
|
||||||
static const settings = '/settings';
|
static const settings = '/settings';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps defaultRoom preference value to route path.
|
/// Maps defaultRoom preference value to route path.
|
||||||
String _routeForDefaultRoom(String? defaultRoom) {
|
String _routeForDefaultRoom(String? defaultRoom) {
|
||||||
return switch (defaultRoom) {
|
// Look up room in registry, fall back to first room's default route
|
||||||
'control-room' => ControlRoomRoutes.containers,
|
final room = roomRegistry.byId(defaultRoom ?? '');
|
||||||
'security' => SecurityRoutes.users,
|
if (room != null) {
|
||||||
'parlor' => AppRoutes.parlor,
|
return room.defaultRoute;
|
||||||
_ => AppRoutes.frontHall,
|
}
|
||||||
};
|
// Default to first registered room (Front Hall)
|
||||||
|
return roomRegistry.all.isNotEmpty
|
||||||
|
? roomRegistry.all.first.defaultRoute
|
||||||
|
: FrontHallRoutes.base;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provides the GoRouter instance.
|
/// Provides the GoRouter instance.
|
||||||
@riverpod
|
@riverpod
|
||||||
GoRouter appRouter(Ref ref) {
|
GoRouter appRouter(Ref ref) {
|
||||||
|
// Initialize room registry on first access
|
||||||
|
_initializeRoomRegistry();
|
||||||
|
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
@@ -56,26 +79,15 @@ GoRouter appRouter(Ref ref) {
|
|||||||
// Root redirects to default room (handled by redirect callback above)
|
// Root redirects to default room (handled by redirect callback above)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.root,
|
path: AppRoutes.root,
|
||||||
redirect: (context, state) => AppRoutes.frontHall,
|
redirect: (context, state) => _routeForDefaultRoom(null),
|
||||||
),
|
),
|
||||||
// Main app routes (inside shell with app scaffold)
|
// Main app routes (inside shell with app scaffold)
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (context, state, child) => AppScaffold(child: child),
|
builder: (context, state, child) => AppScaffold(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
// All room routes from registry
|
||||||
path: AppRoutes.frontHall,
|
...roomRegistry.allRoutes(),
|
||||||
name: 'frontHall',
|
// Settings page (not a room, just a page)
|
||||||
pageBuilder: (context, state) =>
|
|
||||||
noTransitionPage(context, state, const FrontHallPage()),
|
|
||||||
),
|
|
||||||
...controlRoomRoutes(),
|
|
||||||
...securityRoutes(),
|
|
||||||
GoRoute(
|
|
||||||
path: AppRoutes.parlor,
|
|
||||||
name: 'parlor',
|
|
||||||
pageBuilder: (context, state) =>
|
|
||||||
noTransitionPage(context, state, const _PlaceholderPage(title: 'Parlor')),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.settings,
|
path: AppRoutes.settings,
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
@@ -88,38 +100,4 @@ GoRouter appRouter(Ref ref) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder page for routes not yet implemented.
|
|
||||||
class _PlaceholderPage extends StatelessWidget {
|
|
||||||
const _PlaceholderPage({required this.title});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.construction,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(context).colorScheme.outline,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Coming soon',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.outline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
/// Defines a room in the application.
|
||||||
|
///
|
||||||
|
/// Each feature/room registers itself with metadata for navigation,
|
||||||
|
/// routing, and permission-based access control.
|
||||||
|
class RoomDefinition {
|
||||||
|
const RoomDefinition({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.selectedIcon,
|
||||||
|
required this.defaultRoute,
|
||||||
|
required this.routes,
|
||||||
|
this.requiredPermissions = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Unique identifier used in preferences (e.g., 'front-hall', 'control-room').
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Display label for UI.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Icon when not selected.
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
/// Icon when selected.
|
||||||
|
final IconData selectedIcon;
|
||||||
|
|
||||||
|
/// Default route path when room is selected.
|
||||||
|
final String defaultRoute;
|
||||||
|
|
||||||
|
/// All routes this room provides to go_router.
|
||||||
|
final List<RouteBase> Function() routes;
|
||||||
|
|
||||||
|
/// Permissions required to access this room (empty = accessible to all).
|
||||||
|
/// User must have ALL listed permissions.
|
||||||
|
final List<String> requiredPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Central registry of all rooms in the application.
|
||||||
|
///
|
||||||
|
/// Each room's router.dart file registers itself here.
|
||||||
|
/// UI components query this registry to build navigation.
|
||||||
|
class RoomRegistry {
|
||||||
|
RoomRegistry._();
|
||||||
|
|
||||||
|
static final RoomRegistry instance = RoomRegistry._();
|
||||||
|
|
||||||
|
final List<RoomDefinition> _rooms = [];
|
||||||
|
|
||||||
|
/// Register a room. Called by each room's router.dart.
|
||||||
|
void register(RoomDefinition room) {
|
||||||
|
// Avoid duplicate registration
|
||||||
|
if (!_rooms.any((r) => r.id == room.id)) {
|
||||||
|
_rooms.add(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all registered rooms.
|
||||||
|
List<RoomDefinition> get all => List.unmodifiable(_rooms);
|
||||||
|
|
||||||
|
/// Get rooms filtered by user permissions.
|
||||||
|
///
|
||||||
|
/// [userPermissions] is the set of permissions the user has.
|
||||||
|
/// Returns rooms where the user has ALL required permissions.
|
||||||
|
List<RoomDefinition> accessibleTo(Set<String> userPermissions) {
|
||||||
|
return _rooms.where((room) {
|
||||||
|
if (room.requiredPermissions.isEmpty) return true;
|
||||||
|
return room.requiredPermissions.every(userPermissions.contains);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a room by its ID.
|
||||||
|
RoomDefinition? byId(String id) {
|
||||||
|
try {
|
||||||
|
return _rooms.firstWhere((r) => r.id == id);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the index of a room by its ID.
|
||||||
|
int indexOfId(String id) {
|
||||||
|
return _rooms.indexWhere((r) => r.id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the index of a room by route path.
|
||||||
|
int indexOfRoute(String path) {
|
||||||
|
// Find room whose defaultRoute matches or whose routes start with path
|
||||||
|
for (var i = 0; i < _rooms.length; i++) {
|
||||||
|
final room = _rooms[i];
|
||||||
|
if (path == room.defaultRoute || path.startsWith('/${room.id}')) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0; // Default to first room
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all routes from all registered rooms.
|
||||||
|
List<RouteBase> allRoutes() {
|
||||||
|
return _rooms.expand((room) => room.routes()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear registry (useful for testing).
|
||||||
|
void clear() {
|
||||||
|
_rooms.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global registry instance.
|
||||||
|
final roomRegistry = RoomRegistry.instance;
|
||||||
@@ -4,9 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
||||||
import 'package:tatlock_ui/core/config/app_config.dart';
|
import 'package:tatlock_ui/core/config/app_config.dart';
|
||||||
import 'package:tatlock_ui/features/control_room/router.dart';
|
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||||
import 'package:tatlock_ui/features/security/router.dart';
|
|
||||||
import 'package:tatlock_ui/routing/app_router.dart';
|
|
||||||
import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart';
|
import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart';
|
||||||
|
|
||||||
/// Main application scaffold with top header navigation.
|
/// Main application scaffold with top header navigation.
|
||||||
@@ -217,22 +215,13 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
|
|||||||
|
|
||||||
int _selectedIndex(BuildContext context) {
|
int _selectedIndex(BuildContext context) {
|
||||||
final location = GoRouterState.of(context).matchedLocation;
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
|
return roomRegistry.indexOfRoute(location);
|
||||||
if (location.startsWith(ControlRoomRoutes.base)) return 1;
|
|
||||||
if (location.startsWith(SecurityRoutes.base)) return 2;
|
|
||||||
if (location.startsWith(AppRoutes.parlor)) return 3;
|
|
||||||
// Settings is no longer in main nav (accessed via Profile dropdown)
|
|
||||||
return 0; // Front Hall
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onNavSelected(BuildContext context, int index) {
|
void _onNavSelected(BuildContext context, int index) {
|
||||||
final route = switch (index) {
|
final rooms = roomRegistry.all;
|
||||||
0 => AppRoutes.frontHall,
|
if (index >= 0 && index < rooms.length) {
|
||||||
1 => ControlRoomRoutes.containers,
|
context.go(rooms[index].defaultRoute);
|
||||||
2 => SecurityRoutes.users,
|
}
|
||||||
3 => AppRoutes.parlor,
|
|
||||||
_ => AppRoutes.frontHall,
|
|
||||||
};
|
|
||||||
context.go(route);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:math';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
|
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
|
||||||
|
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||||
|
|
||||||
import 'profile_dropdown.dart';
|
import 'profile_dropdown.dart';
|
||||||
|
|
||||||
@@ -24,28 +25,8 @@ class TopHeaderBar extends StatelessWidget {
|
|||||||
static const double _logoCircleRadius = (_logoSize + _logoMargin * 2) / 2; // 64px
|
static const double _logoCircleRadius = (_logoSize + _logoMargin * 2) / 2; // 64px
|
||||||
static const double _bulgeFraction = 0.20; // 20% of circle below header line
|
static const double _bulgeFraction = 0.20; // 20% of circle below header line
|
||||||
|
|
||||||
static const _rooms = [
|
/// Get rooms from the registry.
|
||||||
_RoomDestination(
|
List<RoomDefinition> get _rooms => roomRegistry.all;
|
||||||
icon: Icons.door_front_door_outlined,
|
|
||||||
selectedIcon: Icons.door_front_door,
|
|
||||||
label: 'Front Hall',
|
|
||||||
),
|
|
||||||
_RoomDestination(
|
|
||||||
icon: Icons.dns_outlined,
|
|
||||||
selectedIcon: Icons.dns,
|
|
||||||
label: 'Control Room',
|
|
||||||
),
|
|
||||||
_RoomDestination(
|
|
||||||
icon: Icons.security_outlined,
|
|
||||||
selectedIcon: Icons.security,
|
|
||||||
label: 'Security',
|
|
||||||
),
|
|
||||||
_RoomDestination(
|
|
||||||
icon: Icons.lightbulb_outline,
|
|
||||||
selectedIcon: Icons.lightbulb,
|
|
||||||
label: 'Parlor',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -121,9 +102,10 @@ class TopHeaderBar extends StatelessWidget {
|
|||||||
|
|
||||||
List<Widget> _buildRoomTabs(BuildContext context) {
|
List<Widget> _buildRoomTabs(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final rooms = _rooms;
|
||||||
|
|
||||||
return List.generate(_rooms.length, (index) {
|
return List.generate(rooms.length, (index) {
|
||||||
final room = _rooms[index];
|
final room = rooms[index];
|
||||||
final isSelected = index == selectedIndex;
|
final isSelected = index == selectedIndex;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -257,14 +239,3 @@ class _HeaderPainter extends CustomPainter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RoomDestination {
|
|
||||||
const _RoomDestination({
|
|
||||||
required this.icon,
|
|
||||||
required this.selectedIcon,
|
|
||||||
required this.label,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final IconData selectedIcon;
|
|
||||||
final String label;
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.3.1+1
|
version: 1.4.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
|
|||||||
Reference in New Issue
Block a user