feat: add NPM and Authentik sections to Control Room navigation

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2025-12-31 16:52:10 +01:00
co-authored by Claude Opus 4.5
parent cbf6ca5338
commit ae2bbcd58d
3 changed files with 198 additions and 132 deletions
+112 -33
View File
@@ -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<NavSection> sections;
/// List of navigation items.
final List<NavItem> items;
/// Currently selected section ID.
/// Currently selected item ID.
final String selectedId;
/// Callback when a section is tapped.
final ValueChanged<String> onSectionSelected;
/// Callback when an item is tapped.
final ValueChanged<String> 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<NavItem>)> get _groupedItems {
final groups = <String?, List<NavItem>>{};
final order = <String?>[];
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<Widget> _buildItemList(BuildContext context) {
final widgets = <Widget>[];
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