diff --git a/lib/features/control_room/presentation/pages/control_room_page.dart b/lib/features/control_room/presentation/pages/control_room_page.dart index 0cb41c6..f0fc1c9 100644 --- a/lib/features/control_room/presentation/pages/control_room_page.dart +++ b/lib/features/control_room/presentation/pages/control_room_page.dart @@ -1,37 +1,80 @@ import 'package:flutter/material.dart' hide Stack; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:tatlock_ui/features/control_room/containers/presentation/pages/containers_list_page.dart'; +import 'package:tatlock_ui/features/control_room/router.dart'; import 'package:tatlock_ui/features/control_room/stacks/data/repositories/stack_repository_impl.dart'; import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart'; import 'package:tatlock_ui/features/control_room/stacks/presentation/pages/stack_detail_page.dart'; import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart'; import 'package:tatlock_ui/features/control_room/stacks/presentation/widgets/stack_list_tile.dart'; import 'package:tatlock_ui/shared/layouts/widgets/filter_panel.dart'; +import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart'; -/// Main Control Room page with stacks sidebar and containers view. +/// Control Room navigation sections. +enum ControlRoomSection { + containers('containers', 'Containers', Icons.dns), + networks('networks', 'Networks', Icons.hub), + volumes('volumes', 'Volumes', Icons.storage), + images('images', 'Images', Icons.photo_library); + + const ControlRoomSection(this.id, this.label, this.icon); + + final String id; + final String label; + final IconData icon; + + NavSection toNavSection() => NavSection( + id: id, + label: label, + icon: icon, + ); +} + +/// Main Control Room page with nav panel and section content. class ControlRoomPage extends ConsumerWidget { - const ControlRoomPage({super.key}); + const ControlRoomPage({ + super.key, + this.section = ControlRoomSection.containers, + }); + + /// The current section to display. + final ControlRoomSection section; @override Widget build(BuildContext context, WidgetRef ref) { - final selectedStack = ref.watch(selectedStackProvider); + final colorScheme = Theme.of(context).colorScheme; return Scaffold( body: Row( children: [ - // Stacks sidebar - const _StacksSidebar(), + // Nav Panel - section navigation + NavPanel( + title: 'Sections', + icon: Icons.dns_outlined, + sections: ControlRoomSection.values + .map((s) => s.toNavSection()) + .toList(), + selectedId: section.id, + onSectionSelected: (id) { + final newSection = ControlRoomSection.values.firstWhere( + (s) => s.id == id, + ); + // Navigate to section URL + context.go(pathForSection(newSection)); + // Clear stack selection when changing sections + ref.read(selectedStackProvider.notifier).clear(); + }, + ), // Divider VerticalDivider( width: 1, thickness: 1, - color: Theme.of(context).colorScheme.outlineVariant, + color: colorScheme.outlineVariant, ), - // Main content area - shows either all containers or stack detail + // Section content Expanded( - child: selectedStack == null - ? const ContainersListPage() - : StackDetailPage(stackId: selectedStack), + child: _SectionContent(section: section), ), ], ), @@ -39,9 +82,98 @@ class ControlRoomPage extends ConsumerWidget { } } -/// Sidebar showing stacks list (Filter Panel per UI_LAYOUT.md). -class _StacksSidebar extends ConsumerWidget { - const _StacksSidebar(); +/// Renders content for the selected section. +class _SectionContent extends ConsumerWidget { + const _SectionContent({required this.section}); + + final ControlRoomSection section; + + @override + Widget build(BuildContext context, WidgetRef ref) { + switch (section) { + case ControlRoomSection.containers: + return const _ContainersSection(); + case ControlRoomSection.networks: + case ControlRoomSection.volumes: + case ControlRoomSection.images: + return _PlaceholderSection(section: section); + } + } +} + +/// Containers section with optional stack filter. +class _ContainersSection extends ConsumerWidget { + const _ContainersSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedStack = ref.watch(selectedStackProvider); + final colorScheme = Theme.of(context).colorScheme; + + return Row( + children: [ + // Stacks filter panel + const _StacksFilterPanel(), + // Divider + VerticalDivider( + width: 1, + thickness: 1, + color: colorScheme.outlineVariant, + ), + // Main content - containers list or stack detail + Expanded( + child: selectedStack == null + ? const ContainersListPage() + : StackDetailPage(stackId: selectedStack), + ), + ], + ); + } +} + +/// Placeholder for sections not yet implemented. +class _PlaceholderSection extends StatelessWidget { + const _PlaceholderSection({required this.section}); + + final ControlRoomSection section; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + section.icon, + size: 64, + color: colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + section.label, + style: textTheme.headlineSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + 'Coming soon', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.outline, + ), + ), + ], + ), + ); + } +} + +/// Stacks filter panel for Containers section (includes "All Containers" option). +class _StacksFilterPanel extends ConsumerWidget { + const _StacksFilterPanel(); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/features/control_room/router.dart b/lib/features/control_room/router.dart new file mode 100644 index 0000000..fef8718 --- /dev/null +++ b/lib/features/control_room/router.dart @@ -0,0 +1,77 @@ +import 'package:go_router/go_router.dart'; +import 'package:tatlock_ui/features/control_room/presentation/pages/control_room_page.dart'; + +/// Route paths for Control Room. +abstract class ControlRoomRoutes { + static const base = '/control-room'; + static const containers = '/control-room/containers'; + static const networks = '/control-room/networks'; + static const volumes = '/control-room/volumes'; + static const images = '/control-room/images'; +} + +/// Control Room section from route. +ControlRoomSection? sectionFromPath(String path) { + if (path.startsWith(ControlRoomRoutes.networks)) { + return ControlRoomSection.networks; + } else if (path.startsWith(ControlRoomRoutes.volumes)) { + return ControlRoomSection.volumes; + } else if (path.startsWith(ControlRoomRoutes.images)) { + return ControlRoomSection.images; + } else if (path.startsWith(ControlRoomRoutes.containers) || + path == ControlRoomRoutes.base) { + return ControlRoomSection.containers; + } + return null; +} + +/// Get route path for a section. +String pathForSection(ControlRoomSection section) { + switch (section) { + case ControlRoomSection.containers: + return ControlRoomRoutes.containers; + case ControlRoomSection.networks: + return ControlRoomRoutes.networks; + case ControlRoomSection.volumes: + return ControlRoomRoutes.volumes; + case ControlRoomSection.images: + return ControlRoomRoutes.images; + } +} + +/// Control Room routes for go_router. +List controlRoomRoutes() { + return [ + // Redirect /control-room to /control-room/containers + GoRoute( + path: ControlRoomRoutes.base, + name: 'controlRoom', + redirect: (context, state) => ControlRoomRoutes.containers, + ), + // Section routes + GoRoute( + path: ControlRoomRoutes.containers, + name: 'controlRoomContainers', + builder: (context, state) => + const ControlRoomPage(section: ControlRoomSection.containers), + ), + GoRoute( + path: ControlRoomRoutes.networks, + name: 'controlRoomNetworks', + builder: (context, state) => + const ControlRoomPage(section: ControlRoomSection.networks), + ), + GoRoute( + path: ControlRoomRoutes.volumes, + name: 'controlRoomVolumes', + builder: (context, state) => + const ControlRoomPage(section: ControlRoomSection.volumes), + ), + GoRoute( + path: ControlRoomRoutes.images, + name: 'controlRoomImages', + builder: (context, state) => + const ControlRoomPage(section: ControlRoomSection.images), + ), + ]; +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 307901f..986e7b4 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:tatlock_ui/features/control_room/presentation/pages/control_room_page.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/shared/layouts/app_scaffold.dart'; @@ -10,9 +10,6 @@ part 'app_router.g.dart'; /// Route paths as constants. abstract class AppRoutes { static const frontHall = '/'; - static const controlRoom = '/control-room'; - static const containers = '/control-room/containers'; - static const containerDetail = '/control-room/containers/:id'; static const parlor = '/parlor'; static const settings = '/settings'; } @@ -32,11 +29,7 @@ GoRouter appRouter(AppRouterRef ref) { name: 'frontHall', builder: (context, state) => const FrontHallPage(), ), - GoRoute( - path: AppRoutes.controlRoom, - name: 'controlRoom', - builder: (context, state) => const ControlRoomPage(), - ), + ...controlRoomRoutes(), GoRoute( path: AppRoutes.parlor, name: 'parlor', diff --git a/lib/shared/layouts/app_scaffold.dart b/lib/shared/layouts/app_scaffold.dart index 4d4d701..95a8bc8 100644 --- a/lib/shared/layouts/app_scaffold.dart +++ b/lib/shared/layouts/app_scaffold.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:tatlock_ui/features/control_room/router.dart'; import 'package:tatlock_ui/routing/app_router.dart'; import 'package:tatlock_ui/shared/layouts/widgets/top_header_bar.dart'; @@ -52,7 +53,7 @@ class AppScaffold extends StatelessWidget { int _selectedIndex(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; - if (location.startsWith(AppRoutes.controlRoom)) return 1; + if (location.startsWith(ControlRoomRoutes.base)) return 1; if (location.startsWith(AppRoutes.parlor)) return 2; // Settings is no longer in main nav (accessed via Profile dropdown) return 0; // Front Hall @@ -61,7 +62,7 @@ class AppScaffold extends StatelessWidget { void _onNavSelected(BuildContext context, int index) { final route = switch (index) { 0 => AppRoutes.frontHall, - 1 => AppRoutes.controlRoom, + 1 => ControlRoomRoutes.containers, 2 => AppRoutes.parlor, _ => AppRoutes.frontHall, }; diff --git a/lib/shared/layouts/widgets/nav_panel.dart b/lib/shared/layouts/widgets/nav_panel.dart new file mode 100644 index 0000000..0962978 --- /dev/null +++ b/lib/shared/layouts/widgets/nav_panel.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; + +import 'panel_header.dart'; + +/// A navigation section item in the NavPanel. +class NavSection { + const NavSection({ + required this.id, + required this.label, + required this.icon, + this.badge, + }); + + /// Unique identifier for routing. + final String id; + + /// Display label. + final String label; + + /// Section icon. + final IconData icon; + + /// Optional badge (e.g., item count). + final String? badge; +} + +/// Left-side navigation panel for room-level section navigation. +/// +/// Shows a list of sections within the current room (e.g., Containers, Stacks, +/// Networks for Control Room). Header content is docked to bottom to +/// accommodate the logo bulge overlay. +/// +/// See UI_LAYOUT.md for panel taxonomy and layout specifications. +class NavPanel extends StatelessWidget { + const NavPanel({ + super.key, + required this.title, + required this.icon, + required this.sections, + required this.selectedId, + required this.onSectionSelected, + this.width = 280, + this.trailing, + }); + + /// Panel title displayed in header. + final String title; + + /// Leading icon for the header. + final IconData icon; + + /// List of navigation sections. + final List sections; + + /// Currently selected section ID. + final String selectedId; + + /// Callback when a section is tapped. + final ValueChanged onSectionSelected; + + /// Panel width (default 280px per UI_LAYOUT.md spec). + final double width; + + /// Optional trailing widget below sections (e.g., external links). + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + width: width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PanelHeader( + title: title, + icon: icon, + dockToBottom: true, // Left-side panel + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: sections.length, + itemBuilder: (context, index) { + final section = sections[index]; + final isSelected = section.id == selectedId; + + return _NavTile( + section: section, + isSelected: isSelected, + onTap: () => onSectionSelected(section.id), + ); + }, + ), + ), + if (trailing != null) ...[ + Divider(height: 1, color: colorScheme.outlineVariant), + trailing!, + ], + ], + ), + ); + } +} + +class _NavTile extends StatelessWidget { + const _NavTile({ + required this.section, + required this.isSelected, + required this.onTap, + }); + + final NavSection section; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Material( + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.4) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon( + section.icon, + size: 20, + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + section.label, + style: textTheme.bodyMedium?.copyWith( + color: isSelected + ? colorScheme.primary + : colorScheme.onSurface, + fontWeight: isSelected ? FontWeight.w600 : null, + ), + ), + ), + if (section.badge != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.2) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + section.badge!, + style: textTheme.labelSmall?.copyWith( + color: isSelected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +}