296 lines
9.7 KiB
Dart
296 lines
9.7 KiB
Dart
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
|
import 'package:go_router/go_router.dart';
|
||
|
|
|
||
|
|
import '../connections/connection_providers.dart';
|
||
|
|
import '../routing/route_paths.dart';
|
||
|
|
import '../theme/theme_mode_provider.dart';
|
||
|
|
import '../theme/theme_x.dart';
|
||
|
|
|
||
|
|
class _NavItem {
|
||
|
|
const _NavItem(this.path, this.label, this.icon, this.selectedIcon);
|
||
|
|
final String path;
|
||
|
|
final String label;
|
||
|
|
final IconData icon;
|
||
|
|
final IconData selectedIcon;
|
||
|
|
}
|
||
|
|
|
||
|
|
const _navItems = [
|
||
|
|
_NavItem(RoutePaths.dashboard, 'Dashboard', Icons.grid_view_outlined, Icons.grid_view_rounded),
|
||
|
|
_NavItem(RoutePaths.services, 'Services', Icons.layers_outlined, Icons.layers_rounded),
|
||
|
|
_NavItem(RoutePaths.backups, 'Backups', Icons.cloud_outlined, Icons.cloud_rounded),
|
||
|
|
_NavItem(RoutePaths.fleet, 'Fleet', Icons.hub_outlined, Icons.hub_rounded),
|
||
|
|
_NavItem(RoutePaths.updates, 'Updates', Icons.history_outlined, Icons.history_rounded),
|
||
|
|
_NavItem(RoutePaths.settings, 'Settings', Icons.tune_outlined, Icons.tune_rounded),
|
||
|
|
];
|
||
|
|
|
||
|
|
/// The persistent left nav rail + top bar frame wrapping every screen, per
|
||
|
|
/// the approved mockup. Wraps go_router's `ShellRoute` child.
|
||
|
|
class NavRailShell extends ConsumerWidget {
|
||
|
|
const NavRailShell({super.key, required this.currentPath, required this.child});
|
||
|
|
|
||
|
|
final String currentPath;
|
||
|
|
final Widget child;
|
||
|
|
|
||
|
|
int get _selectedIndex {
|
||
|
|
final index = _navItems.indexWhere((i) => currentPath.startsWith(i.path));
|
||
|
|
return index == -1 ? 0 : index;
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
||
|
|
final narrow = MediaQuery.sizeOf(context).width < 860;
|
||
|
|
|
||
|
|
return Scaffold(
|
||
|
|
body: Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: [
|
||
|
|
_Rail(selectedIndex: _selectedIndex, narrow: narrow),
|
||
|
|
const VerticalDivider(width: 1, thickness: 1),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
_TopBar(currentPath: currentPath),
|
||
|
|
const Divider(height: 1, thickness: 1),
|
||
|
|
Expanded(child: child),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _Rail extends ConsumerWidget {
|
||
|
|
const _Rail({required this.selectedIndex, required this.narrow});
|
||
|
|
|
||
|
|
final int selectedIndex;
|
||
|
|
final bool narrow;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
||
|
|
final activeConnection = ref.watch(activeConnectionProvider);
|
||
|
|
|
||
|
|
return SizedBox(
|
||
|
|
width: narrow ? 72 : 226,
|
||
|
|
child: Container(
|
||
|
|
color: context.theme.navigationRailTheme.backgroundColor,
|
||
|
|
child: SafeArea(
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.fromLTRB(14, 18, 14, 20),
|
||
|
|
child: Row(
|
||
|
|
mainAxisAlignment: narrow ? MainAxisAlignment.center : MainAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Container(
|
||
|
|
width: 28,
|
||
|
|
height: 28,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: context.colors.primary,
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
child: Icon(Icons.hub_rounded, size: 16, color: context.colors.onPrimary),
|
||
|
|
),
|
||
|
|
if (!narrow) ...[
|
||
|
|
const SizedBox(width: 10),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Text('NodeMaster', style: context.text.titleMedium?.copyWith(fontSize: 14.5)),
|
||
|
|
Text('Manager', style: context.text.bodySmall),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: ListView(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
|
|
children: [
|
||
|
|
for (var i = 0; i < _navItems.length; i++)
|
||
|
|
_RailButton(item: _navItems[i], selected: i == selectedIndex, narrow: narrow),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Padding(
|
||
|
|
padding: const EdgeInsets.all(12),
|
||
|
|
child: _ConnectionChip(name: activeConnection?.name, url: activeConnection?.baseUrl, narrow: narrow),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _RailButton extends StatelessWidget {
|
||
|
|
const _RailButton({required this.item, required this.selected, required this.narrow});
|
||
|
|
|
||
|
|
final _NavItem item;
|
||
|
|
final bool selected;
|
||
|
|
final bool narrow;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final fg = selected ? context.colors.secondary : context.text.bodyMedium?.color;
|
||
|
|
final bg = selected ? context.colors.primary.withValues(alpha: 0.14) : Colors.transparent;
|
||
|
|
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 1.5),
|
||
|
|
child: Material(
|
||
|
|
color: bg,
|
||
|
|
borderRadius: BorderRadius.circular(7),
|
||
|
|
child: InkWell(
|
||
|
|
borderRadius: BorderRadius.circular(7),
|
||
|
|
onTap: () => context.go(item.path),
|
||
|
|
child: Padding(
|
||
|
|
padding: EdgeInsets.symmetric(horizontal: narrow ? 0 : 10, vertical: 9),
|
||
|
|
child: Row(
|
||
|
|
mainAxisAlignment: narrow ? MainAxisAlignment.center : MainAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Icon(selected ? item.selectedIcon : item.icon, size: 18, color: fg),
|
||
|
|
if (!narrow) ...[
|
||
|
|
const SizedBox(width: 10),
|
||
|
|
Text(
|
||
|
|
item.label,
|
||
|
|
style: context.text.bodyMedium?.copyWith(
|
||
|
|
color: fg,
|
||
|
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class _ConnectionChip extends StatelessWidget {
|
||
|
|
const _ConnectionChip({required this.name, required this.url, required this.narrow});
|
||
|
|
|
||
|
|
final String? name;
|
||
|
|
final String? url;
|
||
|
|
final bool narrow;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final dot = Container(
|
||
|
|
width: 7,
|
||
|
|
height: 7,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: name == null ? context.colors.outline : context.status.good,
|
||
|
|
shape: BoxShape.circle,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
if (narrow) return Center(child: dot);
|
||
|
|
|
||
|
|
return Container(
|
||
|
|
padding: const EdgeInsets.all(9),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: context.colors.surface,
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
border: Border.all(color: context.colors.outlineVariant),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
dot,
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
name ?? 'No connection',
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: context.text.labelLarge?.copyWith(fontSize: 12),
|
||
|
|
),
|
||
|
|
if (url != null)
|
||
|
|
Text(
|
||
|
|
url!.replaceFirst(RegExp(r'^https?://'), ''),
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
style: context.dataStyles.dataMonoSmall,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const _titles = <String, (String, String Function(WidgetRef))>{
|
||
|
|
RoutePaths.dashboard: ('Dashboard', _connSubtitle),
|
||
|
|
RoutePaths.services: ('Services', _connSubtitle),
|
||
|
|
RoutePaths.backups: ('Backups', _connSubtitle),
|
||
|
|
RoutePaths.fleet: ('Fleet', _connSubtitle),
|
||
|
|
RoutePaths.updates: ('Updates', _connSubtitle),
|
||
|
|
RoutePaths.settings: ('Settings', _connSubtitle),
|
||
|
|
};
|
||
|
|
|
||
|
|
String _connSubtitle(WidgetRef ref) {
|
||
|
|
final connection = ref.watch(activeConnectionProvider);
|
||
|
|
return connection == null ? 'No connection configured' : 'Connected to ${connection.name}';
|
||
|
|
}
|
||
|
|
|
||
|
|
class _TopBar extends ConsumerWidget {
|
||
|
|
const _TopBar({required this.currentPath});
|
||
|
|
|
||
|
|
final String currentPath;
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
||
|
|
final entry = _titles.entries.firstWhere(
|
||
|
|
(e) => currentPath.startsWith(e.key),
|
||
|
|
orElse: () => _titles.entries.first,
|
||
|
|
);
|
||
|
|
final title = entry.value.$1;
|
||
|
|
final subtitle = entry.value.$2(ref);
|
||
|
|
|
||
|
|
final mode = ref.watch(themeModeControllerProvider);
|
||
|
|
final effectiveDark = switch (mode) {
|
||
|
|
ThemeMode.dark => true,
|
||
|
|
ThemeMode.light => false,
|
||
|
|
ThemeMode.system => MediaQuery.platformBrightnessOf(context) == Brightness.dark,
|
||
|
|
};
|
||
|
|
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Text(title, style: context.text.titleLarge),
|
||
|
|
Text(subtitle, style: context.text.bodySmall),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
IconButton(
|
||
|
|
tooltip: effectiveDark ? 'Switch to light theme' : 'Switch to dark theme',
|
||
|
|
icon: Icon(effectiveDark ? Icons.dark_mode_outlined : Icons.light_mode_outlined),
|
||
|
|
onPressed: () => ref
|
||
|
|
.read(themeModeControllerProvider.notifier)
|
||
|
|
.setThemeMode(effectiveDark ? ThemeMode.light : ThemeMode.dark),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|