initial commit
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user