Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f6d27a52 | ||
|
|
40ba6a869d |
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
- Default room preference now applies on app load
|
||||
- Root `/` redirects to user's preferred default room
|
||||
- Front Hall moved to `/front-hall` route (was `/`)
|
||||
|
||||
## [1.3.0] - 2026-01-05
|
||||
|
||||
### Added
|
||||
|
||||
+162
-9
@@ -28,14 +28,20 @@ lib/
|
||||
│ └── 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
|
||||
│ │ ├── stacks/ # Stack management
|
||||
│ │ ├── networks/ # Network management
|
||||
│ │ └── volumes/ # Volume management
|
||||
│ ├── parlor/ # Housekeeping - home automation
|
||||
│ ├── library/ # Knowledge management (future)
|
||||
│ └── study/ # Secretarial tasks (future)
|
||||
│ │ └── 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
|
||||
```
|
||||
|
||||
@@ -45,13 +51,16 @@ lib/
|
||||
|---------|-----------|---------|
|
||||
| Front Hall | `features/front_hall/` | Dashboard, overview, quick access |
|
||||
| Control Room | `features/control_room/` | Infrastructure management |
|
||||
| Parlor | `features/parlor/` | Home automation |
|
||||
| Library | `features/library/` | Knowledge, docs, bookmarks |
|
||||
| Study | `features/study/` | Email, calendar (hidden for now) |
|
||||
| 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:
|
||||
@@ -570,3 +579,147 @@ Semantics(
|
||||
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` |
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.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/routing/app_router.dart';
|
||||
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
|
||||
|
||||
/// Route paths for Control Room.
|
||||
@@ -49,6 +50,22 @@ enum ControlRoomNav {
|
||||
/// Get route path for a nav item.
|
||||
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.
|
||||
List<RouteBase> controlRoomRoutes() {
|
||||
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:tatlock_ui/features/security/presentation/pages/security_page.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';
|
||||
|
||||
/// Route paths for Security room.
|
||||
@@ -47,6 +48,22 @@ enum SecurityNav {
|
||||
/// Get route path for a nav item.
|
||||
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.
|
||||
List<RouteBase> securityRoutes() {
|
||||
return [
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tatlock_ui/core/auth/auth_provider.dart';
|
||||
import 'package:tatlock_ui/core/theme/theme_provider.dart';
|
||||
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||
|
||||
/// Settings page with user preferences.
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
@@ -129,20 +130,14 @@ class SettingsPage extends ConsumerWidget {
|
||||
_updateDefaultRoom(ref, value);
|
||||
}
|
||||
},
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'front-hall',
|
||||
child: Text('Front Hall'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'control-room',
|
||||
child: Text('Control Room'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'parlor',
|
||||
child: Text('Parlor'),
|
||||
),
|
||||
],
|
||||
items: roomRegistry.all
|
||||
.map(
|
||||
(room) => DropdownMenuItem(
|
||||
value: room.id,
|
||||
child: Text(room.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+57
-52
@@ -1,14 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:tatlock_ui/core/auth/auth_provider.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/settings/presentation/pages/settings_page.dart';
|
||||
import 'package:tatlock_ui/routing/room_registry.dart';
|
||||
import 'package:tatlock_ui/shared/layouts/app_scaffold.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
|
||||
Page<void> noTransitionPage(BuildContext context, GoRouterState state, Widget child) {
|
||||
return NoTransitionPage<void>(
|
||||
@@ -19,36 +39,55 @@ Page<void> noTransitionPage(BuildContext context, GoRouterState state, Widget ch
|
||||
|
||||
/// Route paths as constants.
|
||||
abstract class AppRoutes {
|
||||
static const frontHall = '/';
|
||||
static const parlor = '/parlor';
|
||||
static const root = '/';
|
||||
static const settings = '/settings';
|
||||
}
|
||||
|
||||
/// Maps defaultRoom preference value to route path.
|
||||
String _routeForDefaultRoom(String? defaultRoom) {
|
||||
// Look up room in registry, fall back to first room's default route
|
||||
final room = roomRegistry.byId(defaultRoom ?? '');
|
||||
if (room != null) {
|
||||
return room.defaultRoute;
|
||||
}
|
||||
// Default to first registered room (Front Hall)
|
||||
return roomRegistry.all.isNotEmpty
|
||||
? roomRegistry.all.first.defaultRoute
|
||||
: FrontHallRoutes.base;
|
||||
}
|
||||
|
||||
/// Provides the GoRouter instance.
|
||||
@riverpod
|
||||
GoRouter appRouter(Ref ref) {
|
||||
// Initialize room registry on first access
|
||||
_initializeRoomRegistry();
|
||||
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: AppRoutes.frontHall,
|
||||
initialLocation: AppRoutes.root,
|
||||
debugLogDiagnostics: true,
|
||||
redirect: (context, state) {
|
||||
// Redirect from root to user's default room preference
|
||||
if (state.matchedLocation == AppRoutes.root) {
|
||||
final defaultRoom = authState.value?.preferences?.defaultRoom;
|
||||
return _routeForDefaultRoom(defaultRoom);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
// Root redirects to default room (handled by redirect callback above)
|
||||
GoRoute(
|
||||
path: AppRoutes.root,
|
||||
redirect: (context, state) => _routeForDefaultRoom(null),
|
||||
),
|
||||
// Main app routes (inside shell with app scaffold)
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => AppScaffold(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.frontHall,
|
||||
name: 'frontHall',
|
||||
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')),
|
||||
),
|
||||
// All room routes from registry
|
||||
...roomRegistry.allRoutes(),
|
||||
// Settings page (not a room, just a page)
|
||||
GoRoute(
|
||||
path: AppRoutes.settings,
|
||||
name: 'settings',
|
||||
@@ -61,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:tatlock_ui/core/auth/auth_provider.dart';
|
||||
import 'package:tatlock_ui/core/config/app_config.dart';
|
||||
import 'package:tatlock_ui/features/control_room/router.dart';
|
||||
import 'package:tatlock_ui/features/security/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/top_header_bar.dart';
|
||||
|
||||
/// Main application scaffold with top header navigation.
|
||||
@@ -217,22 +215,13 @@ class _AppScaffoldState extends ConsumerState<AppScaffold> {
|
||||
|
||||
int _selectedIndex(BuildContext context) {
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
|
||||
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
|
||||
return roomRegistry.indexOfRoute(location);
|
||||
}
|
||||
|
||||
void _onNavSelected(BuildContext context, int index) {
|
||||
final route = switch (index) {
|
||||
0 => AppRoutes.frontHall,
|
||||
1 => ControlRoomRoutes.containers,
|
||||
2 => SecurityRoutes.users,
|
||||
3 => AppRoutes.parlor,
|
||||
_ => AppRoutes.frontHall,
|
||||
};
|
||||
context.go(route);
|
||||
final rooms = roomRegistry.all;
|
||||
if (index >= 0 && index < rooms.length) {
|
||||
context.go(rooms[index].defaultRoute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
|
||||
import 'package:tatlock_ui/routing/room_registry.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 _bulgeFraction = 0.20; // 20% of circle below header line
|
||||
|
||||
static const _rooms = [
|
||||
_RoomDestination(
|
||||
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',
|
||||
),
|
||||
];
|
||||
/// Get rooms from the registry.
|
||||
List<RoomDefinition> get _rooms => roomRegistry.all;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -121,9 +102,10 @@ class TopHeaderBar extends StatelessWidget {
|
||||
|
||||
List<Widget> _buildRoomTabs(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final rooms = _rooms;
|
||||
|
||||
return List.generate(_rooms.length, (index) {
|
||||
final room = _rooms[index];
|
||||
return List.generate(rooms.length, (index) {
|
||||
final room = rooms[index];
|
||||
final isSelected = index == selectedIndex;
|
||||
|
||||
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
|
||||
# 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.
|
||||
version: 1.3.0+1
|
||||
version: 1.4.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
|
||||
Reference in New Issue
Block a user