From ae2bbcd58d1d826fc45387ecd3b028756186e527 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 31 Dec 2025 16:52:10 +0100 Subject: [PATCH] feat: add NPM and Authentik sections to Control Room navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ControlRoomNav enum with section grouping (Portainer, NPM, Authentik) - Update NavPanel to support conditional section headers - Rename NavSection to NavItem with optional section field - Add routes for NPM: Proxy Hosts, Redirections, Streams, SSL Certificates - Add routes for Authentik: Users, Groups, Applications - Section headers auto-show when multiple sections exist 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../presentation/pages/control_room_page.dart | 72 +++------ lib/features/control_room/router.dart | 113 ++++++++------ lib/shared/layouts/widgets/nav_panel.dart | 145 ++++++++++++++---- 3 files changed, 198 insertions(+), 132 deletions(-) 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 f0fc1c9..8b7d4f4 100644 --- a/lib/features/control_room/presentation/pages/control_room_page.dart +++ b/lib/features/control_room/presentation/pages/control_room_page.dart @@ -11,35 +11,15 @@ import 'package:tatlock_ui/features/control_room/stacks/presentation/widgets/sta import 'package:tatlock_ui/shared/layouts/widgets/filter_panel.dart'; import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart'; -/// 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, - this.section = ControlRoomSection.containers, + this.nav = ControlRoomNav.containers, }); - /// The current section to display. - final ControlRoomSection section; + /// The current nav item to display. + final ControlRoomNav nav; @override Widget build(BuildContext context, WidgetRef ref) { @@ -48,20 +28,18 @@ class ControlRoomPage extends ConsumerWidget { return Scaffold( body: Row( children: [ - // Nav Panel - section navigation + // Nav Panel - section navigation with grouping 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, + items: ControlRoomNav.values.map((n) => n.toNavItem()).toList(), + selectedId: nav.id, + onItemSelected: (id) { + final newNav = ControlRoomNav.values.firstWhere( + (n) => n.id == id, ); // Navigate to section URL - context.go(pathForSection(newSection)); + context.go(pathForNav(newNav)); // Clear stack selection when changing sections ref.read(selectedStackProvider.notifier).clear(); }, @@ -74,7 +52,7 @@ class ControlRoomPage extends ConsumerWidget { ), // Section content Expanded( - child: _SectionContent(section: section), + child: _SectionContent(nav: nav), ), ], ), @@ -82,21 +60,19 @@ class ControlRoomPage extends ConsumerWidget { } } -/// Renders content for the selected section. +/// Renders content for the selected nav item. class _SectionContent extends ConsumerWidget { - const _SectionContent({required this.section}); + const _SectionContent({required this.nav}); - final ControlRoomSection section; + final ControlRoomNav nav; @override Widget build(BuildContext context, WidgetRef ref) { - switch (section) { - case ControlRoomSection.containers: + switch (nav) { + case ControlRoomNav.containers: return const _ContainersSection(); - case ControlRoomSection.networks: - case ControlRoomSection.volumes: - case ControlRoomSection.images: - return _PlaceholderSection(section: section); + default: + return _PlaceholderSection(nav: nav); } } } @@ -131,11 +107,11 @@ class _ContainersSection extends ConsumerWidget { } } -/// Placeholder for sections not yet implemented. +/// Placeholder for nav items not yet implemented. class _PlaceholderSection extends StatelessWidget { - const _PlaceholderSection({required this.section}); + const _PlaceholderSection({required this.nav}); - final ControlRoomSection section; + final ControlRoomNav nav; @override Widget build(BuildContext context) { @@ -147,20 +123,20 @@ class _PlaceholderSection extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - section.icon, + nav.icon, size: 64, color: colorScheme.outline, ), const SizedBox(height: 16), Text( - section.label, + nav.label, style: textTheme.headlineSmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 8), Text( - 'Coming soon', + '${nav.section} • Coming soon', style: textTheme.bodyMedium?.copyWith( color: colorScheme.outline, ), diff --git a/lib/features/control_room/router.dart b/lib/features/control_room/router.dart index fef8718..4aa1712 100644 --- a/lib/features/control_room/router.dart +++ b/lib/features/control_room/router.dart @@ -1,43 +1,64 @@ +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/shared/layouts/widgets/nav_panel.dart'; /// Route paths for Control Room. abstract class ControlRoomRoutes { static const base = '/control-room'; + // Portainer static const containers = '/control-room/containers'; static const networks = '/control-room/networks'; static const volumes = '/control-room/volumes'; static const images = '/control-room/images'; + // NPM + static const proxyHosts = '/control-room/proxy-hosts'; + static const redirections = '/control-room/redirections'; + static const streams = '/control-room/streams'; + static const certificates = '/control-room/certificates'; + // Authentik + static const users = '/control-room/users'; + static const groups = '/control-room/groups'; + static const applications = '/control-room/applications'; } -/// 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; +/// Control Room navigation items with section grouping. +enum ControlRoomNav { + // Portainer section + containers('containers', 'Containers', Icons.dns, 'Portainer'), + networks('networks', 'Networks', Icons.hub, 'Portainer'), + volumes('volumes', 'Volumes', Icons.storage, 'Portainer'), + images('images', 'Images', Icons.photo_library, 'Portainer'), + // NPM section + proxyHosts('proxy-hosts', 'Proxy Hosts', Icons.public, 'NPM'), + redirections('redirections', 'Redirections', Icons.alt_route, 'NPM'), + streams('streams', 'Streams', Icons.stream, 'NPM'), + certificates('certificates', 'SSL Certificates', Icons.verified_user, 'NPM'), + // Authentik section + users('users', 'Users', Icons.people, 'Authentik'), + groups('groups', 'Groups', Icons.group_work, 'Authentik'), + applications('applications', 'Applications', Icons.apps, 'Authentik'); + + const ControlRoomNav(this.id, this.label, this.icon, this.section); + + final String id; + final String label; + final IconData icon; + final String section; + + NavItem toNavItem() => NavItem( + id: id, + label: label, + icon: icon, + section: section, + ); + + /// Get route path for this nav item. + String get path => '/control-room/$id'; } -/// 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; - } -} +/// Get route path for a nav item. +String pathForNav(ControlRoomNav nav) => nav.path; /// Control Room routes for go_router. List controlRoomRoutes() { @@ -48,30 +69,20 @@ List controlRoomRoutes() { 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), - ), + // Generate routes for all nav items + for (final nav in ControlRoomNav.values) + GoRoute( + path: nav.path, + name: 'controlRoom${_capitalize(nav.id.replaceAll('-', '_'))}', + builder: (context, state) => ControlRoomPage(nav: nav), + ), ]; } + +String _capitalize(String s) { + if (s.isEmpty) return s; + return s + .split('_') + .map((part) => part[0].toUpperCase() + part.substring(1)) + .join(''); +} diff --git a/lib/shared/layouts/widgets/nav_panel.dart b/lib/shared/layouts/widgets/nav_panel.dart index 0962978..9f2227e 100644 --- a/lib/shared/layouts/widgets/nav_panel.dart +++ b/lib/shared/layouts/widgets/nav_panel.dart @@ -2,12 +2,13 @@ import 'package:flutter/material.dart'; import 'panel_header.dart'; -/// A navigation section item in the NavPanel. -class NavSection { - const NavSection({ +/// A navigation item in the NavPanel. +class NavItem { + const NavItem({ required this.id, required this.label, required this.icon, + this.section, this.badge, }); @@ -17,18 +18,24 @@ class NavSection { /// Display label. final String label; - /// Section icon. + /// Item icon. final IconData icon; + /// Optional section grouping (e.g., 'Portainer', 'NPM', 'Authentik'). + /// Section headers are shown only when multiple sections exist. + final String? section; + /// 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. +/// Shows a list of items within the current room (e.g., Containers, Networks +/// for Control Room). Items can be grouped into sections with headers. +/// Section headers only appear when multiple sections exist. +/// +/// 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 { @@ -36,9 +43,9 @@ class NavPanel extends StatelessWidget { super.key, required this.title, required this.icon, - required this.sections, + required this.items, required this.selectedId, - required this.onSectionSelected, + required this.onItemSelected, this.width = 280, this.trailing, }); @@ -49,21 +56,43 @@ class NavPanel extends StatelessWidget { /// Leading icon for the header. final IconData icon; - /// List of navigation sections. - final List sections; + /// List of navigation items. + final List items; - /// Currently selected section ID. + /// Currently selected item ID. final String selectedId; - /// Callback when a section is tapped. - final ValueChanged onSectionSelected; + /// Callback when an item is tapped. + final ValueChanged onItemSelected; /// Panel width (default 280px per UI_LAYOUT.md spec). final double width; - /// Optional trailing widget below sections (e.g., external links). + /// Optional trailing widget below items (e.g., external links). final Widget? trailing; + /// Returns true if section headers should be shown. + bool get _showSectionHeaders { + final sections = items.map((i) => i.section).where((s) => s != null).toSet(); + return sections.length > 1; + } + + /// Groups items by section, preserving order. + List<(String?, List)> get _groupedItems { + final groups = >{}; + final order = []; + + for (final item in items) { + if (!groups.containsKey(item.section)) { + groups[item.section] = []; + order.add(item.section); + } + groups[item.section]!.add(item); + } + + return order.map((section) => (section, groups[section]!)).toList(); + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -79,19 +108,9 @@ class NavPanel extends StatelessWidget { dockToBottom: true, // Left-side panel ), Expanded( - child: ListView.builder( + child: ListView( 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), - ); - }, + children: _buildItemList(context), ), ), if (trailing != null) ...[ @@ -102,16 +121,76 @@ class NavPanel extends StatelessWidget { ), ); } + + List _buildItemList(BuildContext context) { + final widgets = []; + final showHeaders = _showSectionHeaders; + + for (final (section, sectionItems) in _groupedItems) { + // Add section header if multiple sections exist + if (showHeaders && section != null) { + widgets.add(_SectionHeader(title: section)); + } + + // Add items + for (final item in sectionItems) { + widgets.add( + _NavTile( + item: item, + isSelected: item.id == selectedId, + onTap: () => onItemSelected(item.id), + ), + ); + } + } + + return widgets; + } +} + +/// Section header widget with left accent bar. +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + border: Border( + left: BorderSide( + color: colorScheme.primary, + width: 2, + ), + ), + ), + child: Text( + title.toUpperCase(), + style: textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + fontWeight: FontWeight.w600, + ), + ), + ); + } } class _NavTile extends StatelessWidget { const _NavTile({ - required this.section, + required this.item, required this.isSelected, required this.onTap, }); - final NavSection section; + final NavItem item; final bool isSelected; final VoidCallback onTap; @@ -135,7 +214,7 @@ class _NavTile extends StatelessWidget { child: Row( children: [ Icon( - section.icon, + item.icon, size: 20, color: isSelected ? colorScheme.primary @@ -144,7 +223,7 @@ class _NavTile extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - section.label, + item.label, style: textTheme.bodyMedium?.copyWith( color: isSelected ? colorScheme.primary @@ -153,7 +232,7 @@ class _NavTile extends StatelessWidget { ), ), ), - if (section.badge != null) + if (item.badge != null) Container( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -166,7 +245,7 @@ class _NavTile extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: Text( - section.badge!, + item.badge!, style: textTheme.labelSmall?.copyWith( color: isSelected ? colorScheme.primary