diff --git a/CHANGELOG.md b/CHANGELOG.md index bb60d0c..3319630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10376bc..6c76ecc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 + requiredPermissions: [], // Empty = accessible to all +); + +/// Register with the central registry. +void registerControlRoom() { + roomRegistry.register(controlRoomRoom); +} + +/// Routes for go_router. +List controlRoomRoutes() { + return [ + GoRoute(path: '/control-room', ...), + // Sub-routes... + ]; +} +``` + +### Registration Order + +Rooms are registered in `app_router.dart` in display order: + +```dart +void _initializeRoomRegistry() { + if (roomRegistry.all.isNotEmpty) return; // Skip if initialized + + // Registration order = tab order + registerFrontHall(); + registerControlRoom(); + registerSecurity(); + registerParlor(); + registerMediaRoom(); +} +``` + +### Using the Registry + +**Navigation tabs** (`top_header_bar.dart`): +```dart +List get _rooms => roomRegistry.all; + +// Build tab for each room +for (final room in _rooms) { + IconButton( + icon: Icon(isSelected ? room.selectedIcon : room.icon), + onPressed: () => onRoomSelected(index), + ); +} +``` + +**Settings dropdown**: +```dart +DropdownButton( + items: roomRegistry.all + .map((room) => DropdownMenuItem( + value: room.id, + child: Text(room.label), + )) + .toList(), +); +``` + +**Router** - All routes from registry: +```dart +ShellRoute( + routes: [ + ...roomRegistry.allRoutes(), + // Plus non-room routes like /settings + ], +); +``` + +**Route matching**: +```dart +int _selectedIndex(BuildContext context) { + final location = GoRouterState.of(context).matchedLocation; + return roomRegistry.indexOfRoute(location); +} +``` + +### Permission Filtering + +Rooms can specify required permissions: + +```dart +final adminRoom = RoomDefinition( + id: 'admin', + requiredPermissions: ['admin:access'], + // ... +); + +// Filter by user permissions +final accessibleRooms = roomRegistry.accessibleTo(userPermissions); +``` + +### Adding a New Room + +1. Create feature folder: `lib/features/{room_name}/` +2. Create `router.dart` with `RoomDefinition` and register function +3. Create placeholder page in `presentation/pages/{room_name}_page.dart` +4. Add `register{RoomName}()` call to `_initializeRoomRegistry()` in `app_router.dart` +5. Import the router in `app_router.dart` + +### Current Rooms + +| Room | ID | Default Route | +|------|-----|---------------| +| Front Hall | `front-hall` | `/front-hall` | +| Control Room | `control-room` | `/control-room/containers` | +| Security | `security` | `/security/users` | +| Parlor | `parlor` | `/parlor` | +| Media Room | `media-room` | `/media-room` | diff --git a/lib/features/control_room/router.dart b/lib/features/control_room/router.dart index ebddfbe..543b378 100644 --- a/lib/features/control_room/router.dart +++ b/lib/features/control_room/router.dart @@ -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 controlRoomRoutes() { return [ diff --git a/lib/features/front_hall/router.dart b/lib/features/front_hall/router.dart new file mode 100644 index 0000000..322ea1e --- /dev/null +++ b/lib/features/front_hall/router.dart @@ -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 frontHallRoutes() { + return [ + GoRoute( + path: FrontHallRoutes.base, + name: 'frontHall', + pageBuilder: (context, state) => + noTransitionPage(context, state, const FrontHallPage()), + ), + ]; +} diff --git a/lib/features/media_room/presentation/pages/media_room_page.dart b/lib/features/media_room/presentation/pages/media_room_page.dart new file mode 100644 index 0000000..a2fc252 --- /dev/null +++ b/lib/features/media_room/presentation/pages/media_room_page.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/media_room/router.dart b/lib/features/media_room/router.dart new file mode 100644 index 0000000..4d4e31b --- /dev/null +++ b/lib/features/media_room/router.dart @@ -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 mediaRoomRoutes() { + return [ + GoRoute( + path: MediaRoomRoutes.base, + name: 'mediaRoom', + pageBuilder: (context, state) => + noTransitionPage(context, state, const MediaRoomPage()), + ), + ]; +} diff --git a/lib/features/parlor/presentation/pages/parlor_page.dart b/lib/features/parlor/presentation/pages/parlor_page.dart new file mode 100644 index 0000000..9eb4829 --- /dev/null +++ b/lib/features/parlor/presentation/pages/parlor_page.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/parlor/router.dart b/lib/features/parlor/router.dart new file mode 100644 index 0000000..1381764 --- /dev/null +++ b/lib/features/parlor/router.dart @@ -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 parlorRoutes() { + return [ + GoRoute( + path: ParlorRoutes.base, + name: 'parlor', + pageBuilder: (context, state) => + noTransitionPage(context, state, const ParlorPage()), + ), + ]; +} diff --git a/lib/features/security/router.dart b/lib/features/security/router.dart index 764b1fa..b3d9fd5 100644 --- a/lib/features/security/router.dart +++ b/lib/features/security/router.dart @@ -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 securityRoutes() { return [ diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart index 45de5db..0611567 100644 --- a/lib/features/settings/presentation/pages/settings_page.dart +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -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(), ), ], ), diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index ad09d68..c54f327 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -3,13 +3,32 @@ 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 noTransitionPage(BuildContext context, GoRouterState state, Widget child) { return NoTransitionPage( @@ -21,24 +40,28 @@ Page noTransitionPage(BuildContext context, GoRouterState state, Widget ch /// Route paths as constants. abstract class AppRoutes { static const root = '/'; - static const frontHall = '/front-hall'; - static const parlor = '/parlor'; static const settings = '/settings'; } /// Maps defaultRoom preference value to route path. String _routeForDefaultRoom(String? defaultRoom) { - return switch (defaultRoom) { - 'control-room' => ControlRoomRoutes.containers, - 'security' => SecurityRoutes.users, - 'parlor' => AppRoutes.parlor, - _ => AppRoutes.frontHall, - }; + // 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( @@ -56,26 +79,15 @@ GoRouter appRouter(Ref ref) { // Root redirects to default room (handled by redirect callback above) GoRoute( path: AppRoutes.root, - redirect: (context, state) => AppRoutes.frontHall, + 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', @@ -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, - ), - ), - ], - ), - ); - } -} diff --git a/lib/routing/room_registry.dart b/lib/routing/room_registry.dart new file mode 100644 index 0000000..d868416 --- /dev/null +++ b/lib/routing/room_registry.dart @@ -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 Function() routes; + + /// Permissions required to access this room (empty = accessible to all). + /// User must have ALL listed permissions. + final List 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 _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 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 accessibleTo(Set 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 allRoutes() { + return _rooms.expand((room) => room.routes()).toList(); + } + + /// Clear registry (useful for testing). + void clear() { + _rooms.clear(); + } +} + +/// Global registry instance. +final roomRegistry = RoomRegistry.instance; diff --git a/lib/shared/layouts/app_scaffold.dart b/lib/shared/layouts/app_scaffold.dart index 3870700..d2c2d72 100644 --- a/lib/shared/layouts/app_scaffold.dart +++ b/lib/shared/layouts/app_scaffold.dart @@ -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 { 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); + } } } diff --git a/lib/shared/layouts/widgets/top_header_bar.dart b/lib/shared/layouts/widgets/top_header_bar.dart index 65f5e34..c0a06ac 100644 --- a/lib/shared/layouts/widgets/top_header_bar.dart +++ b/lib/shared/layouts/widgets/top_header_bar.dart @@ -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 get _rooms => roomRegistry.all; @override Widget build(BuildContext context) { @@ -121,9 +102,10 @@ class TopHeaderBar extends StatelessWidget { List _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; -} diff --git a/pubspec.yaml b/pubspec.yaml index b5315a7..c13f200 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.1+1 +version: 1.4.0+1 environment: sdk: ^3.10.4