Files
kaptan/ground-suite/lib/app/app_shell.dart
T

260 lines
7.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/diagnostics/presentation/diagnostics_page.dart';
import '../features/observability/presentation/observability_controller.dart';
import '../features/overview/presentation/overview_page.dart';
import '../shared/widgets/glass_panel.dart';
enum _Destination { overview, diagnostics }
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
_Destination _destination = _Destination.overview;
@override
Widget build(BuildContext context) {
final state = ref.watch(observabilityControllerProvider);
const pages = <Widget>[OverviewPage(), DiagnosticsPage()];
return Scaffold(
body: GroundSuiteBackdrop(
child: SafeArea(
child: Stack(
children: [
LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 760;
final content = _Workspace(
state: state,
selectedIndex: _destination.index,
pages: pages,
compact: compact,
);
return compact
? _CompactShell(
selectedIndex: _destination.index,
onSelected: _selectDestination,
content: content,
)
: _WideShell(
selectedIndex: _destination.index,
onSelected: _selectDestination,
content: content,
);
},
),
if (state.accessibilityAnnouncement case final announcement?)
_CriticalAnnouncement(message: announcement),
],
),
),
),
);
}
void _selectDestination(int index) {
setState(() => _destination = _Destination.values[index]);
}
}
class _WideShell extends StatelessWidget {
const _WideShell({
required this.selectedIndex,
required this.onSelected,
required this.content,
});
final int selectedIndex;
final ValueChanged<int> onSelected;
final Widget content;
@override
Widget build(BuildContext context) {
return Row(
children: [
GlassPanel(
borderRadius: BorderRadius.zero,
child: NavigationRail(
selectedIndex: selectedIndex,
labelType: NavigationRailLabelType.all,
onDestinationSelected: onSelected,
leading: const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Icon(Icons.flight_takeoff_outlined, size: 28),
),
destinations: _destinations,
),
),
const VerticalDivider(width: 1),
Expanded(child: content),
],
);
}
}
class _CompactShell extends StatelessWidget {
const _CompactShell({
required this.selectedIndex,
required this.onSelected,
required this.content,
});
final int selectedIndex;
final ValueChanged<int> onSelected;
final Widget content;
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(child: content),
GlassPanel(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: onSelected,
destinations: const [
NavigationDestination(
icon: Icon(Icons.space_dashboard_outlined),
selectedIcon: Icon(Icons.space_dashboard),
label: 'Overview',
),
NavigationDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: 'Diagnostics',
),
],
),
),
],
);
}
}
const _destinations = <NavigationRailDestination>[
NavigationRailDestination(
icon: Icon(Icons.space_dashboard_outlined),
selectedIcon: Icon(Icons.space_dashboard),
label: Text('Overview'),
),
NavigationRailDestination(
icon: Icon(Icons.receipt_long_outlined),
selectedIcon: Icon(Icons.receipt_long),
label: Text('Diagnostics'),
),
];
class _Workspace extends StatelessWidget {
const _Workspace({
required this.state,
required this.selectedIndex,
required this.pages,
required this.compact,
});
final ObservabilityState state;
final int selectedIndex;
final List<Widget> pages;
final bool compact;
@override
Widget build(BuildContext context) {
return Column(
children: [
_ConnectionBar(state: state, compact: compact),
const Divider(height: 1),
Expanded(
child: IndexedStack(index: selectedIndex, children: pages),
),
],
);
}
}
class _ConnectionBar extends StatelessWidget {
const _ConnectionBar({required this.state, required this.compact});
final ObservabilityState state;
final bool compact;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final heartbeat = state.lastHeartbeat;
final peer = heartbeat == null
? 'No vehicle identity observed'
: 'System ${heartbeat.systemId} / component ${heartbeat.componentId}';
final frameTime = state.lastFrameAt == null
? 'No frames received'
: 'Last frame ${_formatTimestamp(state.lastFrameAt!)}';
return Semantics(
label: '${state.status.label}; ${state.sourceLabel}; $peer; $frameTime',
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: compact ? 16 : 24,
vertical: 12,
),
child: GlassPanel(
child: Padding(
padding: const EdgeInsets.all(12),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Icon(
Icons.visibility_outlined,
color: theme.colorScheme.primary,
),
Text('Observation only', style: theme.textTheme.titleSmall),
_StatusChip(label: state.sourceLabel),
_StatusChip(label: state.status.label),
Text(peer),
Text(frameTime),
],
),
),
),
),
);
}
}
class _CriticalAnnouncement extends StatelessWidget {
const _CriticalAnnouncement({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Semantics(
container: true,
liveRegion: true,
label: message,
child: const SizedBox.square(dimension: 1),
);
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Chip(label: Text(label), visualDensity: VisualDensity.compact);
}
}
String _formatTimestamp(DateTime value) => value.toIso8601String();