chore: release v1.4.0
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m7s

- 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:
Jeroen Schweitzer
2026-01-05 15:46:44 +01:00
co-authored by Claude Opus 4.5
parent 40ba6a869d
commit c3f6d27a52
15 changed files with 581 additions and 134 deletions
+36 -58
View File
@@ -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<void> noTransitionPage(BuildContext context, GoRouterState state, Widget child) {
return NoTransitionPage<void>(
@@ -21,24 +40,28 @@ Page<void> 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,
),
),
],
),
);
}
}
+113
View File
@@ -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;