- Created lib/core/semantics/ with semantic ID constants and helper widget
- Enabled SemanticsBinding on web builds for accessibility tree exposure
- Added semantic IDs to:
- ProfileDropdown (button, settings, theme options, logout)
- TopHeaderBar room tabs (frontHall, controlRoom, security, parlor)
- NavPanel items (nav_item_{id})
This enables browser automation tools like Puppeteer and WebDriver to
discover and interact with Flutter widgets via the accessibility tree.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
271 lines
7.5 KiB
Dart
271 lines
7.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:tatlock_ui/core/semantics/semantic_ids.dart';
|
|
|
|
import 'panel_header.dart';
|
|
|
|
/// A navigation item in the NavPanel.
|
|
class NavItem {
|
|
const NavItem({
|
|
required this.id,
|
|
required this.label,
|
|
required this.icon,
|
|
this.section,
|
|
this.badge,
|
|
});
|
|
|
|
/// Unique identifier for routing.
|
|
final String id;
|
|
|
|
/// Display label.
|
|
final String label;
|
|
|
|
/// 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 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 {
|
|
const NavPanel({
|
|
super.key,
|
|
required this.title,
|
|
required this.icon,
|
|
required this.items,
|
|
required this.selectedId,
|
|
required this.onItemSelected,
|
|
this.width = 280,
|
|
this.trailing,
|
|
});
|
|
|
|
/// Panel title displayed in header.
|
|
final String title;
|
|
|
|
/// Leading icon for the header.
|
|
final IconData icon;
|
|
|
|
/// List of navigation items.
|
|
final List<NavItem> items;
|
|
|
|
/// Currently selected item ID.
|
|
final String selectedId;
|
|
|
|
/// 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 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;
|
|
|
|
return SizedBox(
|
|
width: width,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
PanelHeader(
|
|
title: title,
|
|
icon: icon,
|
|
dockToBottom: true, // Left-side panel
|
|
),
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
children: _buildItemList(context),
|
|
),
|
|
),
|
|
if (trailing != null) ...[
|
|
Divider(height: 1, color: colorScheme.outlineVariant),
|
|
trailing!,
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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.item,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
});
|
|
|
|
final NavItem item;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
return Semantics(
|
|
identifier: NavSemantics.item(item.id),
|
|
label: item.label,
|
|
button: true,
|
|
selected: isSelected,
|
|
child: 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(
|
|
item.icon,
|
|
size: 20,
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
item.label,
|
|
style: textTheme.bodyMedium?.copyWith(
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.onSurface,
|
|
fontWeight: isSelected ? FontWeight.w600 : null,
|
|
),
|
|
),
|
|
),
|
|
if (item.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(
|
|
item.badge!,
|
|
style: textTheme.labelSmall?.copyWith(
|
|
color: isSelected
|
|
? colorScheme.primary
|
|
: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|