feat(ground-suite): add tactical command workspace
This commit is contained in:
1 parent
00a3a9f908
commit
35e0fd8741
10 files changed
+5435
-874
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
@@ -3,20 +3,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import 'app_shell.dart';
|
import 'app_shell.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'theme_mode_controller.dart';
|
|
||||||
|
|
||||||
class GroundSuiteApp extends ConsumerWidget {
|
class GroundSuiteApp extends StatelessWidget {
|
||||||
const GroundSuiteApp({super.key});
|
const GroundSuiteApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) => ProviderScope(
|
||||||
return MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'Ground Suite',
|
title: 'Ground Suite',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: groundSuiteLightTheme(),
|
theme: groundSuiteDarkTheme(),
|
||||||
darkTheme: groundSuiteDarkTheme(),
|
themeMode: ThemeMode.dark,
|
||||||
themeMode: ref.watch(themeModeProvider),
|
|
||||||
home: const AppShell(),
|
home: const AppShell(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../features/command/presentation/tactical_command_page.dart';
|
||||||
import '../features/diagnostics/presentation/diagnostics_page.dart';
|
import '../features/diagnostics/presentation/diagnostics_page.dart';
|
||||||
import '../features/observability/presentation/observability_controller.dart';
|
import '../features/observability/presentation/observability_controller.dart';
|
||||||
import '../features/overview/presentation/overview_page.dart';
|
|
||||||
import '../shared/widgets/surfaces.dart';
|
|
||||||
import 'theme_mode_controller.dart';
|
|
||||||
|
|
||||||
enum _Destination { vehicle, events }
|
enum _ApplicationSurface { tactical, eventStream }
|
||||||
|
|
||||||
|
/// Owns the two first-class Ground Suite surfaces. Tactical planning never
|
||||||
|
/// hides decoded diagnostics or critical accessibility announcements.
|
||||||
class AppShell extends ConsumerStatefulWidget {
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
const AppShell({super.key});
|
const AppShell({super.key});
|
||||||
|
|
||||||
@@ -17,425 +17,55 @@ class AppShell extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppShellState extends ConsumerState<AppShell> {
|
class _AppShellState extends ConsumerState<AppShell> {
|
||||||
_Destination _destination = _Destination.vehicle;
|
_ApplicationSurface _surface = _ApplicationSurface.tactical;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(observabilityControllerProvider);
|
final observation = ref.watch(observabilityControllerProvider);
|
||||||
const pages = <Widget>[OverviewPage(), DiagnosticsPage()];
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: AppCanvas(
|
body: Stack(
|
||||||
child: SafeArea(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
children: [
|
||||||
LayoutBuilder(
|
switch (_surface) {
|
||||||
builder: (context, constraints) {
|
_ApplicationSurface.tactical => TacticalCommandPage(
|
||||||
final textScale =
|
sourceLabel: observation.sourceLabel,
|
||||||
MediaQuery.textScalerOf(context).scale(14) / 14;
|
sourceStatus: observation.status.label,
|
||||||
final compact =
|
onOpenEventStream: () =>
|
||||||
constraints.maxWidth < 960 || textScale > 1.25;
|
setState(() => _surface = _ApplicationSurface.eventStream),
|
||||||
final workspace = _Workspace(
|
),
|
||||||
state: state,
|
_ApplicationSurface.eventStream => _EventStreamWorkspace(
|
||||||
selectedIndex: _destination.index,
|
onReturnToTactical: () =>
|
||||||
pages: pages,
|
setState(() => _surface = _ApplicationSurface.tactical),
|
||||||
compact: compact,
|
),
|
||||||
);
|
|
||||||
return compact
|
|
||||||
? _CompactShell(
|
|
||||||
selectedIndex: _destination.index,
|
|
||||||
onSelected: _selectDestination,
|
|
||||||
workspace: workspace,
|
|
||||||
)
|
|
||||||
: _DesktopShell(
|
|
||||||
selectedIndex: _destination.index,
|
|
||||||
onSelected: _selectDestination,
|
|
||||||
workspace: workspace,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
Semantics(
|
||||||
if (state.accessibilityAnnouncement case final announcement?)
|
|
||||||
_CriticalAnnouncement(message: announcement),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _selectDestination(int index) {
|
|
||||||
setState(() => _destination = _Destination.values[index]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DesktopShell extends StatelessWidget {
|
|
||||||
const _DesktopShell({
|
|
||||||
required this.selectedIndex,
|
|
||||||
required this.onSelected,
|
|
||||||
required this.workspace,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int selectedIndex;
|
|
||||||
final ValueChanged<int> onSelected;
|
|
||||||
final Widget workspace;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
FrostedChrome(
|
|
||||||
child: SizedBox(
|
|
||||||
width: 216,
|
|
||||||
child: NavigationRail(
|
|
||||||
extended: true,
|
|
||||||
minExtendedWidth: 216,
|
|
||||||
selectedIndex: selectedIndex,
|
|
||||||
labelType: NavigationRailLabelType.none,
|
|
||||||
onDestinationSelected: onSelected,
|
|
||||||
leading: const _BrandBlock(),
|
|
||||||
trailing: const Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(12, 0, 12, 16),
|
|
||||||
child: AppearanceMenu(showLabel: true),
|
|
||||||
),
|
|
||||||
destinations: const [
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.radar_outlined),
|
|
||||||
selectedIcon: Icon(Icons.radar),
|
|
||||||
label: Text('Vehicle observation'),
|
|
||||||
),
|
|
||||||
NavigationRailDestination(
|
|
||||||
icon: Icon(Icons.segment_outlined),
|
|
||||||
selectedIcon: Icon(Icons.segment),
|
|
||||||
label: Text('Event stream'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(child: workspace),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BrandBlock extends StatelessWidget {
|
|
||||||
const _BrandBlock();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 16, 20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.flight_takeoff_outlined, color: theme.colorScheme.primary),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const ConsoleLabel('Kaptan'),
|
|
||||||
Text('Ground Suite', style: theme.textTheme.titleLarge),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text('Observation console', style: theme.textTheme.bodySmall),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CompactShell extends StatelessWidget {
|
|
||||||
const _CompactShell({
|
|
||||||
required this.selectedIndex,
|
|
||||||
required this.onSelected,
|
|
||||||
required this.workspace,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int selectedIndex;
|
|
||||||
final ValueChanged<int> onSelected;
|
|
||||||
final Widget workspace;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
Expanded(child: workspace),
|
|
||||||
FrostedChrome(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
|
||||||
child: Row(
|
|
||||||
key: const Key('compact-navigation'),
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _CompactDestinationButton(
|
|
||||||
selected: selectedIndex == _Destination.vehicle.index,
|
|
||||||
icon: Icons.radar_outlined,
|
|
||||||
selectedIcon: Icons.radar,
|
|
||||||
label: 'Vehicle',
|
|
||||||
onTap: () => onSelected(_Destination.vehicle.index),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _CompactDestinationButton(
|
|
||||||
selected: selectedIndex == _Destination.events.index,
|
|
||||||
icon: Icons.segment_outlined,
|
|
||||||
selectedIcon: Icons.segment,
|
|
||||||
label: 'Events',
|
|
||||||
onTap: () => onSelected(_Destination.events.index),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CompactDestinationButton extends StatelessWidget {
|
|
||||||
const _CompactDestinationButton({
|
|
||||||
required this.selected,
|
|
||||||
required this.icon,
|
|
||||||
required this.selectedIcon,
|
|
||||||
required this.label,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final bool selected;
|
|
||||||
final IconData icon;
|
|
||||||
final IconData selectedIcon;
|
|
||||||
final String label;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final tone = selected ? scheme.primary : scheme.onSurfaceVariant;
|
|
||||||
return Semantics(
|
|
||||||
button: true,
|
|
||||||
selected: selected,
|
|
||||||
label: label,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(selected ? selectedIcon : icon, color: tone),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
||||||
color: tone,
|
|
||||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: [
|
|
||||||
_OperationalStatusStrip(state: state, compact: compact),
|
|
||||||
Expanded(child: pages[selectedIndex]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _OperationalStatusStrip extends StatelessWidget {
|
|
||||||
const _OperationalStatusStrip({required this.state, required this.compact});
|
|
||||||
|
|
||||||
final ObservabilityState state;
|
|
||||||
final bool compact;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final heartbeat = state.lastHeartbeat;
|
|
||||||
final dark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
final replayTone = dark ? const Color(0xff70b8ff) : const Color(0xff175cd3);
|
|
||||||
final source = state.isReplay ? 'Replay · fixture' : state.sourceLabel;
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final textScale = MediaQuery.textScalerOf(context).scale(14) / 14;
|
|
||||||
final showFacts =
|
|
||||||
!compact && constraints.maxWidth >= 900 && textScale <= 1.25;
|
|
||||||
return FrostedChrome(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: compact ? 16 : 20,
|
|
||||||
vertical: 10,
|
|
||||||
),
|
|
||||||
child: showFacts
|
|
||||||
? Row(
|
|
||||||
children: [
|
|
||||||
StatusBadge(label: source, color: replayTone),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const StatusBadge(label: 'Observation only'),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
state.status.label,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const VerticalDivider(indent: 4, endIndent: 4),
|
|
||||||
Text(
|
|
||||||
heartbeat == null
|
|
||||||
? 'Awaiting reported heartbeat'
|
|
||||||
: 'SYS ${heartbeat.systemId} · COMP ${heartbeat.componentId}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
'Ingress ${state.parserDropCount} / ${state.semanticDropCount}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
state.lastFrameAt == null
|
|
||||||
? 'No observations'
|
|
||||||
: state.isReplay
|
|
||||||
? 'Replay ended ${_shortTimestamp(state.lastFrameAt!)}'
|
|
||||||
: 'Last ${_shortTimestamp(state.lastFrameAt!)}',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 6,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
StatusBadge(label: source, color: replayTone),
|
|
||||||
const StatusBadge(label: 'Observation only'),
|
|
||||||
const AppearanceMenu(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppearanceMenu extends ConsumerWidget {
|
|
||||||
const AppearanceMenu({super.key, this.showLabel = false});
|
|
||||||
|
|
||||||
final bool showLabel;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final mode = ref.watch(themeModeProvider);
|
|
||||||
final label = _appearanceLabel(mode);
|
|
||||||
|
|
||||||
return Semantics(
|
|
||||||
button: true,
|
|
||||||
label: 'Appearance: $label',
|
|
||||||
child: Tooltip(
|
|
||||||
message: 'Appearance: $label',
|
|
||||||
child: PopupMenuButton<ThemeMode>(
|
|
||||||
initialValue: mode,
|
|
||||||
constraints: const BoxConstraints(minWidth: 300, maxWidth: 360),
|
|
||||||
onSelected: ref.read(themeModeProvider.notifier).select,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(_appearanceIcon(mode), size: 20),
|
|
||||||
if (showLabel) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'Appearance',
|
|
||||||
style: Theme.of(context).textTheme.labelLarge,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
const PopupMenuItem(
|
|
||||||
enabled: false,
|
|
||||||
child: ConsoleLabel('Appearance'),
|
|
||||||
),
|
|
||||||
_appearanceItem(
|
|
||||||
ThemeMode.system,
|
|
||||||
mode,
|
|
||||||
'System default (recommended)',
|
|
||||||
),
|
|
||||||
_appearanceItem(ThemeMode.light, mode, 'Light'),
|
|
||||||
_appearanceItem(ThemeMode.dark, mode, 'Dark'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PopupMenuItem<ThemeMode> _appearanceItem(
|
|
||||||
ThemeMode value,
|
|
||||||
ThemeMode selected,
|
|
||||||
String label,
|
|
||||||
) => PopupMenuItem(
|
|
||||||
value: value,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(value == selected ? Icons.check : Icons.circle_outlined, size: 18),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CriticalAnnouncement extends StatelessWidget {
|
|
||||||
const _CriticalAnnouncement({required this.message});
|
|
||||||
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Semantics(
|
|
||||||
container: true,
|
|
||||||
liveRegion: true,
|
liveRegion: true,
|
||||||
label: message,
|
label: observation.accessibilityAnnouncement ?? '',
|
||||||
child: const SizedBox.square(dimension: 1),
|
child: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _appearanceLabel(ThemeMode mode) => switch (mode) {
|
class _EventStreamWorkspace extends StatelessWidget {
|
||||||
ThemeMode.system => 'System default',
|
const _EventStreamWorkspace({required this.onReturnToTactical});
|
||||||
ThemeMode.light => 'Light',
|
|
||||||
ThemeMode.dark => 'Dark',
|
|
||||||
};
|
|
||||||
|
|
||||||
IconData _appearanceIcon(ThemeMode mode) => switch (mode) {
|
final VoidCallback onReturnToTactical;
|
||||||
ThemeMode.system => Icons.brightness_auto_outlined,
|
|
||||||
ThemeMode.light => Icons.light_mode_outlined,
|
|
||||||
ThemeMode.dark => Icons.dark_mode_outlined,
|
|
||||||
};
|
|
||||||
|
|
||||||
String _shortTimestamp(DateTime value) =>
|
@override
|
||||||
'${value.hour.toString().padLeft(2, '0')}:'
|
Widget build(BuildContext context) => Stack(
|
||||||
'${value.minute.toString().padLeft(2, '0')}:'
|
children: [
|
||||||
'${value.second.toString().padLeft(2, '0')}Z';
|
const Positioned.fill(child: DiagnosticsPage()),
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
right: 20,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: onReturnToTactical,
|
||||||
|
icon: const Icon(Icons.map_outlined),
|
||||||
|
label: const Text('Tactical workspace'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
+57
-104
@@ -1,117 +1,70 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
/// A restrained green accent, used sparingly against neutral surfaces.
|
/// Ground Suite's single visual language: a dark tactical operations deck.
|
||||||
const accentGreen = Color(0xff36d681);
|
const panelBackground = Color(0xD912141A);
|
||||||
|
const panelBorder = Color(0x1AFFFFFF);
|
||||||
|
const textPrimary = Color(0xFFF8FAFC);
|
||||||
|
const textSecondary = Color(0xFF94A3B8);
|
||||||
|
const accentOrange = Color(0xFFF97316);
|
||||||
|
const accentMint = Color(0xFF34D399);
|
||||||
|
const statusGreen = Color(0xFF10B981);
|
||||||
|
const statusRed = Color(0xFFEF4444);
|
||||||
|
|
||||||
ThemeData groundSuiteLightTheme() => _theme(
|
const tacticalBlack = Color(0xFF000000);
|
||||||
brightness: Brightness.light,
|
const tacticalTransparent = Color(0x00000000);
|
||||||
seed: const Color(0xff087a3b),
|
|
||||||
surface: const Color(0xfff3f6f3),
|
|
||||||
onSurface: const Color(0xff142019),
|
|
||||||
outline: const Color(0xffc2cec4),
|
|
||||||
);
|
|
||||||
|
|
||||||
ThemeData groundSuiteDarkTheme() => _theme(
|
ThemeData groundSuiteDarkTheme() {
|
||||||
brightness: Brightness.dark,
|
final base = ThemeData.dark(useMaterial3: true);
|
||||||
seed: const Color(0xff36d681),
|
final interTextTheme = GoogleFonts.interTextTheme(
|
||||||
surface: const Color(0xff080b09),
|
base.textTheme,
|
||||||
onSurface: const Color(0xffe8f0ea),
|
).apply(bodyColor: textPrimary, displayColor: textPrimary);
|
||||||
outline: const Color(0xff2a382e),
|
|
||||||
);
|
|
||||||
|
|
||||||
ThemeData _theme({
|
return base.copyWith(
|
||||||
required Brightness brightness,
|
scaffoldBackgroundColor: tacticalBlack,
|
||||||
required Color seed,
|
cardColor: panelBackground,
|
||||||
required Color surface,
|
colorScheme: const ColorScheme.dark(
|
||||||
required Color onSurface,
|
primary: accentMint,
|
||||||
required Color outline,
|
onPrimary: tacticalBlack,
|
||||||
}) {
|
secondary: accentOrange,
|
||||||
final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: brightness)
|
onSecondary: tacticalBlack,
|
||||||
.copyWith(
|
surface: panelBackground,
|
||||||
primary: seed,
|
onSurface: textPrimary,
|
||||||
onPrimary: brightness == Brightness.dark
|
error: statusRed,
|
||||||
? const Color(0xff031207)
|
onError: textPrimary,
|
||||||
: Colors.white,
|
outline: panelBorder,
|
||||||
surface: surface,
|
|
||||||
onSurface: onSurface,
|
|
||||||
onSurfaceVariant: brightness == Brightness.dark
|
|
||||||
? const Color(0xffa8b5ac)
|
|
||||||
: const Color(0xff546258),
|
|
||||||
outline: outline,
|
|
||||||
surfaceContainer: brightness == Brightness.dark
|
|
||||||
? const Color(0xff101712)
|
|
||||||
: const Color(0xffeaf0eb),
|
|
||||||
);
|
|
||||||
|
|
||||||
return ThemeData(
|
|
||||||
useMaterial3: true,
|
|
||||||
colorScheme: scheme,
|
|
||||||
scaffoldBackgroundColor: Colors.transparent,
|
|
||||||
visualDensity: VisualDensity.standard,
|
|
||||||
cardTheme: const CardThemeData(
|
|
||||||
color: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
),
|
),
|
||||||
textTheme: ThemeData(brightness: brightness).textTheme.copyWith(
|
textTheme: interTextTheme.copyWith(
|
||||||
headlineMedium: TextStyle(
|
displayLarge: interTextTheme.displayLarge?.copyWith(color: textPrimary),
|
||||||
color: onSurface,
|
displayMedium: interTextTheme.displayMedium?.copyWith(color: textPrimary),
|
||||||
fontWeight: FontWeight.w700,
|
headlineLarge: interTextTheme.headlineLarge?.copyWith(color: textPrimary),
|
||||||
letterSpacing: -0.6,
|
headlineMedium: interTextTheme.headlineMedium?.copyWith(
|
||||||
|
color: textPrimary,
|
||||||
),
|
),
|
||||||
titleLarge: TextStyle(color: onSurface, fontWeight: FontWeight.w700),
|
headlineSmall: interTextTheme.headlineSmall?.copyWith(color: textPrimary),
|
||||||
titleMedium: TextStyle(color: onSurface, fontWeight: FontWeight.w600),
|
titleLarge: interTextTheme.titleLarge?.copyWith(color: textPrimary),
|
||||||
bodyMedium: TextStyle(color: onSurface),
|
titleMedium: interTextTheme.titleMedium?.copyWith(color: textPrimary),
|
||||||
bodySmall: TextStyle(
|
titleSmall: interTextTheme.titleSmall?.copyWith(color: textPrimary),
|
||||||
color: brightness == Brightness.dark
|
bodyLarge: interTextTheme.bodyLarge?.copyWith(color: textPrimary),
|
||||||
? const Color(0xffa8b5ac)
|
bodyMedium: interTextTheme.bodyMedium?.copyWith(color: textPrimary),
|
||||||
: const Color(0xff546258),
|
bodySmall: interTextTheme.bodySmall?.copyWith(color: textSecondary),
|
||||||
),
|
labelLarge: interTextTheme.labelLarge?.copyWith(color: textPrimary),
|
||||||
),
|
labelMedium: interTextTheme.labelMedium?.copyWith(color: textSecondary),
|
||||||
dividerTheme: DividerThemeData(color: outline.withValues(alpha: 0.72)),
|
labelSmall: interTextTheme.labelSmall?.copyWith(color: textSecondary),
|
||||||
navigationRailTheme: NavigationRailThemeData(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
indicatorColor: seed.withValues(
|
|
||||||
alpha: brightness == Brightness.dark ? 0.2 : 0.13,
|
|
||||||
),
|
|
||||||
selectedIconTheme: IconThemeData(color: seed),
|
|
||||||
selectedLabelTextStyle: TextStyle(
|
|
||||||
color: seed,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
navigationBarTheme: NavigationBarThemeData(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
indicatorColor: seed.withValues(
|
|
||||||
alpha: brightness == Brightness.dark ? 0.2 : 0.13,
|
|
||||||
),
|
|
||||||
labelTextStyle: WidgetStatePropertyAll(
|
|
||||||
TextStyle(color: onSurface, fontWeight: FontWeight.w600),
|
|
||||||
),
|
),
|
||||||
|
cardTheme: const CardThemeData(color: panelBackground, elevation: 0),
|
||||||
|
dividerTheme: const DividerThemeData(color: panelBorder),
|
||||||
|
progressIndicatorTheme: ProgressIndicatorThemeData(
|
||||||
|
color: accentMint,
|
||||||
|
linearTrackColor: textSecondary.withValues(alpha: .2),
|
||||||
),
|
),
|
||||||
chipTheme: ChipThemeData(
|
chipTheme: ChipThemeData(
|
||||||
backgroundColor: scheme.surfaceContainer.withValues(alpha: 0.72),
|
backgroundColor: tacticalTransparent,
|
||||||
selectedColor: seed.withValues(
|
selectedColor: accentMint.withValues(alpha: .1),
|
||||||
alpha: brightness == Brightness.dark ? 0.22 : 0.16,
|
side: const BorderSide(color: panelBorder),
|
||||||
),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
side: BorderSide(color: outline.withValues(alpha: 0.75)),
|
labelStyle: GoogleFonts.inter(color: textSecondary),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
secondaryLabelStyle: GoogleFonts.inter(color: textPrimary),
|
||||||
),
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
|
||||||
filled: true,
|
|
||||||
fillColor: scheme.surfaceContainer.withValues(alpha: 0.7),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
borderSide: BorderSide(color: outline),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
borderSide: BorderSide(color: outline.withValues(alpha: 0.8)),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
borderSide: BorderSide(color: seed, width: 1.5),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
final themeModeProvider = NotifierProvider<ThemeModeController, ThemeMode>(
|
|
||||||
ThemeModeController.new,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Holds the local display preference for the current application session.
|
|
||||||
///
|
|
||||||
/// It intentionally starts at the system preference. Persisting an operator's
|
|
||||||
/// visual preference can be added later without coupling it to vehicle state.
|
|
||||||
class ThemeModeController extends Notifier<ThemeMode> {
|
|
||||||
@override
|
|
||||||
ThemeMode build() => ThemeMode.system;
|
|
||||||
|
|
||||||
void select(ThemeMode mode) => state = mode;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,283 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import '../../../core/observations/observed_event.dart';
|
|
||||||
import '../../../shared/widgets/surfaces.dart';
|
|
||||||
import '../../observability/presentation/observability_controller.dart';
|
|
||||||
|
|
||||||
class OverviewPage extends ConsumerWidget {
|
|
||||||
const OverviewPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final state = ref.watch(observabilityControllerProvider);
|
|
||||||
final heartbeat = state.lastHeartbeat;
|
|
||||||
final observedStreams = state.observedTelemetryKinds;
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final padding = constraints.maxWidth < 600 ? 16.0 : 28.0;
|
|
||||||
final columns = constraints.maxWidth >= 1120
|
|
||||||
? 3
|
|
||||||
: constraints.maxWidth >= 700
|
|
||||||
? 2
|
|
||||||
: 1;
|
|
||||||
final panelWidth =
|
|
||||||
(constraints.maxWidth - (padding * 2) - (16 * (columns - 1))) /
|
|
||||||
columns;
|
|
||||||
|
|
||||||
return ListView(
|
|
||||||
padding: EdgeInsets.all(padding),
|
|
||||||
children: [
|
|
||||||
const ConsoleLabel('Vehicle observation'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Replay session',
|
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
'Read-only observations from the selected source.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Wrap(
|
|
||||||
spacing: 16,
|
|
||||||
runSpacing: 16,
|
|
||||||
children: [
|
|
||||||
_MetricPanel(
|
|
||||||
width: panelWidth,
|
|
||||||
label: 'Replay session',
|
|
||||||
value: state.sourceLabel,
|
|
||||||
accent: Theme.of(context).colorScheme.secondary,
|
|
||||||
child: const Text(
|
|
||||||
'Deterministic fixture. No physical vehicle is connected.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_MetricPanel(
|
|
||||||
width: panelWidth,
|
|
||||||
label: 'Last reported heartbeat',
|
|
||||||
value: heartbeat == null
|
|
||||||
? 'Awaiting heartbeat'
|
|
||||||
: 'SYS ${heartbeat.systemId} · COMP ${heartbeat.componentId}',
|
|
||||||
monoValue: true,
|
|
||||||
child: _HeartbeatFacts(heartbeat: heartbeat),
|
|
||||||
),
|
|
||||||
_MetricPanel(
|
|
||||||
width: panelWidth,
|
|
||||||
label: 'Ingress accounting',
|
|
||||||
value:
|
|
||||||
'${state.parserDropCount} / ${state.semanticDropCount}',
|
|
||||||
monoValue: true,
|
|
||||||
child: Text(
|
|
||||||
state.isReplay
|
|
||||||
? 'Parser / semantic drops. Not evaluated for typed replay.'
|
|
||||||
: 'Parser / semantic drops from received frames.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
InstrumentPanel(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const ConsoleLabel('Observed data streams'),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Wrap(
|
|
||||||
spacing: 10,
|
|
||||||
runSpacing: 10,
|
|
||||||
children: [
|
|
||||||
for (final kind in TelemetryKind.values)
|
|
||||||
_StreamIndicator(
|
|
||||||
kind: kind,
|
|
||||||
observed: observedStreams.contains(kind),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
InstrumentPanel(
|
|
||||||
accent: Theme.of(context).colorScheme.outline,
|
|
||||||
child: const Padding(
|
|
||||||
padding: EdgeInsets.all(20),
|
|
||||||
child: _UnavailableSetup(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MetricPanel extends StatelessWidget {
|
|
||||||
const _MetricPanel({
|
|
||||||
required this.width,
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
required this.child,
|
|
||||||
this.accent,
|
|
||||||
this.monoValue = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
final double width;
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
final Widget child;
|
|
||||||
final Color? accent;
|
|
||||||
final bool monoValue;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => SizedBox(
|
|
||||||
width: width,
|
|
||||||
child: InstrumentPanel(
|
|
||||||
accent: accent,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ConsoleLabel(label),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
fontFamily: monoValue ? 'monospace' : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
DefaultTextStyle.merge(
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HeartbeatFacts extends StatelessWidget {
|
|
||||||
const _HeartbeatFacts({required this.heartbeat});
|
|
||||||
|
|
||||||
final HeartbeatEvent? heartbeat;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (heartbeat == null) {
|
|
||||||
return const Text('No heartbeat has been reported by this source.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_FactLine(
|
|
||||||
label: 'Reported arm state',
|
|
||||||
value: heartbeat!.armed ? 'ARMED' : 'DISARMED',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
_FactLine(
|
|
||||||
label: 'Reported MAV state',
|
|
||||||
value: heartbeat!.vehicleState.toUpperCase(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FactLine extends StatelessWidget {
|
|
||||||
const _FactLine({required this.label, required this.value});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => RichText(
|
|
||||||
text: TextSpan(
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
children: [
|
|
||||||
TextSpan(text: '$label: '),
|
|
||||||
TextSpan(
|
|
||||||
text: value,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StreamIndicator extends StatelessWidget {
|
|
||||||
const _StreamIndicator({required this.kind, required this.observed});
|
|
||||||
|
|
||||||
final TelemetryKind kind;
|
|
||||||
final bool observed;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final tone = observed ? scheme.primary : scheme.onSurfaceVariant;
|
|
||||||
return DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: tone.withValues(alpha: observed ? 0.12 : 0.06),
|
|
||||||
border: Border.all(
|
|
||||||
color: tone.withValues(alpha: observed ? 0.5 : 0.32),
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
|
||||||
child: Wrap(
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
observed
|
|
||||||
? Icons.check_circle_outline
|
|
||||||
: Icons.remove_circle_outline,
|
|
||||||
size: 17,
|
|
||||||
color: tone,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 7),
|
|
||||||
Text(kind.label),
|
|
||||||
const SizedBox(width: 7),
|
|
||||||
Text(
|
|
||||||
observed ? 'Observed' : 'Not observed',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UnavailableSetup extends StatelessWidget {
|
|
||||||
const _UnavailableSetup();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.lock_outline),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const ConsoleLabel('Vehicle setup · unavailable'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text('This firmware accepts no inbound MAVLink authority.'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import 'app/app.dart';
|
import 'app/app.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const ProviderScope(child: GroundSuiteApp()));
|
runApp(const GroundSuiteApp());
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ class AppCanvas extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final dark = Theme.of(context).brightness == Brightness.dark;
|
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||||
final base = dark ? const Color(0xff080b09) : const Color(0xfff3f6f3);
|
final base = dark ? const Color(0xff080b09) : const Color(0xfff3f6f3);
|
||||||
final primaryHaze = (dark ? accentGreen : const Color(0xff087a3b))
|
final primaryHaze = (dark ? accentMint : const Color(0xff087a3b))
|
||||||
.withValues(alpha: dark ? 0.075 : 0.05);
|
.withValues(alpha: dark ? 0.075 : 0.05);
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
|
|||||||
@@ -1,96 +1,683 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ground_suite/app/app.dart';
|
import 'package:ground_suite/app/app.dart';
|
||||||
import 'package:ground_suite/app/app_shell.dart';
|
import 'package:ground_suite/app/app_shell.dart';
|
||||||
import 'package:ground_suite/shared/widgets/surfaces.dart';
|
import 'package:ground_suite/app/theme.dart';
|
||||||
|
import 'package:ground_suite/features/command/presentation/tactical_command_page.dart';
|
||||||
|
import 'package:ground_suite/features/command/presentation/tactical_controller.dart';
|
||||||
|
import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
|
||||||
|
import 'package:ground_suite/features/vehicle_workspace/presentation/vehicle_workspace_controller.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('replay workspace keeps diagnostics distinct and read-only', (
|
testWidgets('desktop command dashboard opens the grouped tactical controls', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
|
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
|
||||||
expect(app.themeMode, ThemeMode.system);
|
expect(app.themeMode, ThemeMode.dark);
|
||||||
expect(app.theme?.brightness, Brightness.light);
|
expect(find.text('TACTICAL COMMAND'), findsOneWidget);
|
||||||
expect(app.darkTheme?.brightness, Brightness.dark);
|
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
|
||||||
expect(find.byType(FrostedChrome), findsAtLeastNWidgets(1));
|
expect(find.text('DEMO-1021'), findsAtLeastNWidgets(1));
|
||||||
expect(find.text('OBSERVATION ONLY'), findsOneWidget);
|
expect(find.byKey(const Key('units-menu')), findsOneWidget);
|
||||||
expect(find.text('Replay fixture'), findsAtLeastNWidgets(1));
|
expect(find.byKey(const Key('cursor-menu')), findsOneWidget);
|
||||||
expect(find.text('LAST REPORTED HEARTBEAT'), findsOneWidget);
|
expect(find.byKey(const Key('view-menu')), findsOneWidget);
|
||||||
|
expect(find.text('UNITS'), findsNothing);
|
||||||
|
expect(find.text('MODE: SELECTION'), findsNothing);
|
||||||
|
expect(find.text('VIEW'), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('units-menu'))),
|
||||||
|
const Size(48, 48),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('cursor-menu'))),
|
||||||
|
const Size(48, 48),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('view-menu'))),
|
||||||
|
const Size(48, 48),
|
||||||
|
);
|
||||||
|
|
||||||
await tester.tap(find.byTooltip('Appearance: System default'));
|
await tester.tap(find.byKey(const Key('units-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Add local unit'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.text('Requires verified source-to-unit binding'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add local unit'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Dark'));
|
expect(find.text('UNIT HANGAR'), findsOneWidget);
|
||||||
|
expect(find.text('LOCAL SCENE'), findsAtLeastNWidgets(1));
|
||||||
|
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('hangar-card-kaptan.vehicle.fixed-wing.local')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('hangar-card-kaptan.vehicle.tiltrotor-vtol.local')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('hangar-card-kaptan.vehicle.helicopter.local')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('hangar-card-kaptan.vehicle.rover.local')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('hangar-card-kaptan.vehicle.surface-vessel.local')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('PREPARE UNIT'), findsOneWidget);
|
||||||
|
expect(find.text('QUADCOPTER-01'), findsNothing);
|
||||||
|
expect(
|
||||||
|
ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
).read(tacticalControllerProvider).unitDraft,
|
||||||
|
isNotNull,
|
||||||
|
);
|
||||||
|
|
||||||
|
final rectangle = await tester.startGesture(const Offset(620, 500));
|
||||||
|
await rectangle.moveTo(const Offset(760, 560));
|
||||||
|
await rectangle.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final draftState = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
).read(tacticalControllerProvider);
|
||||||
|
expect(draftState.panel, TacticalPanel.unitHangar);
|
||||||
|
expect(draftState.unitDraft?.definition.name, 'Quadcopter');
|
||||||
|
|
||||||
|
await tester.tap(find.text('PREPARE UNIT'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('UNIT SETUP'), findsOneWidget);
|
||||||
|
expect(find.text('VEHICLE CONFIGURATION UNAVAILABLE'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'Unavailable until verified binding, compatible schema, command authority, and safe activation are available.',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('ADD TO LOCAL SCENE'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
).read(tacticalControllerProvider).mode,
|
||||||
|
isA<TacticalBrowseMode>(),
|
||||||
|
);
|
||||||
|
expect(find.text('QUADCOPTER-01'), findsAtLeastNWidgets(1));
|
||||||
|
|
||||||
|
await tester.tap(find.text('SET UP'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('LOCAL UNIT SETUP'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('local-unit-name-field')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dark tactical theme is fixed and uses the palette tokens', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final context = tester.element(find.byType(AppShell));
|
||||||
expect(
|
expect(
|
||||||
tester.widget<MaterialApp>(find.byType(MaterialApp)).themeMode,
|
tester.widget<MaterialApp>(find.byType(MaterialApp)).themeMode,
|
||||||
ThemeMode.dark,
|
ThemeMode.dark,
|
||||||
);
|
);
|
||||||
|
expect(Theme.of(context).brightness, Brightness.dark);
|
||||||
|
expect(Theme.of(context).cardColor, panelBackground);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Text>(
|
||||||
|
find.text(
|
||||||
|
'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.style
|
||||||
|
?.color,
|
||||||
|
accentOrange,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.segment_outlined));
|
testWidgets('selection console stays local and exposes no vehicle action', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Event timeline'), findsOneWidget);
|
await tester.tap(find.byKey(const Key('unit-card-DEMO-1023')));
|
||||||
await tester.tap(find.text('Errors 1'));
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(
|
expect(
|
||||||
find.text('kaptan_drivers::lidar_lite_v3::error::Error::TransferFault'),
|
find.text('LOCAL VISUAL PREVIEW · NO VEHICLE ACTION'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
expect(find.text('DEPLOY'), findsNothing);
|
||||||
|
expect(find.text('CONFIGURE'), findsNothing);
|
||||||
|
|
||||||
await tester.tap(find.text('Shutdown reports 1'));
|
await tester.tap(find.text('EVENT STREAM'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Event timeline'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Tactical workspace'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('TACTICAL COMMAND'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pencil cursor draws directly on the map with its toolbar', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Orderly shutdown report'), findsOneWidget);
|
ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
).read(tacticalControllerProvider.notifier).beginPencil();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('pencil-hue')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('pencil-saturation')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('pencil-brightness')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('pencil-thickness')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('pencil-swatch-282')));
|
||||||
|
await tester.drag(
|
||||||
|
find.byKey(const Key('pencil-thickness')),
|
||||||
|
const Offset(70, 0),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
final pencil =
|
||||||
|
ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
).read(tacticalControllerProvider).mode
|
||||||
|
as TacticalPencilMode;
|
||||||
|
expect(pencil.hue, 282);
|
||||||
|
expect(pencil.thickness, greaterThan(3));
|
||||||
|
|
||||||
|
final stroke = await tester.startGesture(const Offset(620, 500));
|
||||||
|
await stroke.moveTo(const Offset(680, 540));
|
||||||
|
await tester.pump(const Duration(milliseconds: 20));
|
||||||
|
await stroke.moveTo(const Offset(740, 490));
|
||||||
|
await tester.pump(const Duration(milliseconds: 20));
|
||||||
|
await stroke.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
);
|
||||||
|
expect(container.read(tacticalControllerProvider).drawings, hasLength(1));
|
||||||
|
expect(find.text('DRAWING'), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('tactical-map-overlay-painter'))),
|
||||||
|
isNot(Size.zero),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('marker cursor names a clicked local marker separately', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('cursor-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Marker cursor'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('MARKER CURSOR'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('marker-hue')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('marker-saturation')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('marker-brightness')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('marker-swatch-208')));
|
||||||
|
await tester.tapAt(const Offset(700, 520));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('marker-name-field')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('marker-name-field')),
|
||||||
|
'North staging note',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('SAVE MARKER'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final state = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
).read(tacticalControllerProvider);
|
||||||
|
expect(state.markers, hasLength(1));
|
||||||
|
expect(state.markers.single.name, 'North staging note');
|
||||||
|
expect(state.markers.single.hue, 208);
|
||||||
|
expect(state.drawings, isEmpty);
|
||||||
|
expect(find.text('North staging note'), findsAtLeastNWidgets(1));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a Pencil click alone never creates a marker or drawing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
);
|
||||||
|
container.read(tacticalControllerProvider.notifier).beginPencil();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tapAt(const Offset(700, 520));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final state = container.read(tacticalControllerProvider);
|
||||||
|
expect(state.drawings, isEmpty);
|
||||||
|
expect(state.markers, isEmpty);
|
||||||
|
expect(find.byKey(const Key('marker-name-field')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('map marker selection synchronizes the local selection console', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('map-unit-DEMO-1023')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('DEMO-1023'), findsAtLeastNWidgets(1));
|
||||||
|
expect(
|
||||||
|
ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
).read(tacticalControllerProvider).selectedUnitIds,
|
||||||
|
{'DEMO-1023'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dragging the tactical canvas creates a local group selection', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final gesture = await tester.startGesture(const Offset(660, 520));
|
||||||
|
await gesture.moveTo(const Offset(960, 745));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final selected = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
).read(tacticalControllerProvider).selectedUnitIds;
|
||||||
|
expect(selected, containsAll(['DEMO-1021', 'DEMO-1023']));
|
||||||
|
expect(find.text('LOCAL GROUP SELECTION'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'compact desktop layout uses bottom navigation without overflow',
|
'map deselection and hidden demo overlays never select invisibly',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await tester.binding.setSurfaceSize(const Size(480, 800));
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('compact-navigation')), findsOneWidget);
|
await tester.tap(find.byKey(const Key('map-unit-DEMO-1023')));
|
||||||
expect(tester.takeException(), isNull);
|
await tester.pump();
|
||||||
|
await tester.tapAt(const Offset(600, 500));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container.read(tacticalControllerProvider).selectedUnitIds,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('view-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Hide Drones'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final gesture = await tester.startGesture(const Offset(660, 520));
|
||||||
|
await gesture.moveTo(const Offset(960, 745));
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
container.read(tacticalControllerProvider).selectedUnitIds,
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
testWidgets('follows a dark system preference', (tester) async {
|
testWidgets('selection cursor ends pencil mode and restores selection', (
|
||||||
tester.binding.platformDispatcher.platformBrightnessTestValue =
|
|
||||||
Brightness.dark;
|
|
||||||
addTearDown(
|
|
||||||
tester.binding.platformDispatcher.clearPlatformBrightnessTestValue,
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
final context = tester.element(find.byType(AppShell));
|
|
||||||
expect(Theme.of(context).brightness, Brightness.dark);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('large desktop text uses the compact session strip', (
|
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.binding.setSurfaceSize(const Size(1280, 900));
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('cursor-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Pencil cursor'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('SELECTION CURSOR'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('PENCIL CURSOR'), findsNothing);
|
||||||
|
expect(FocusManager.instance.primaryFocus, isNotNull);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hangar selection prepares a local unit before confirmation', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('units-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Add local unit'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const Key('hangar-card-kaptan.vehicle.quadcopter.local')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final scope = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
);
|
||||||
|
expect(scope.read(tacticalControllerProvider).unitDraft, isNotNull);
|
||||||
|
expect(scope.read(vehicleWorkspaceProvider).units, isEmpty);
|
||||||
|
|
||||||
|
await tester.tap(find.text('PREPARE UNIT'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final name = find.byKey(const Key('local-unit-name-field'));
|
||||||
|
await tester.tap(name);
|
||||||
|
await tester.enterText(name, 'North survey');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('ADD TO LOCAL SCENE'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('North survey'), findsAtLeastNWidgets(1));
|
||||||
|
expect(scope.read(vehicleWorkspaceProvider).units, hasLength(1));
|
||||||
|
expect(
|
||||||
|
scope.read(vehicleWorkspaceProvider).units.single.displayName,
|
||||||
|
'North survey',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
).read(tacticalControllerProvider).mode,
|
||||||
|
isA<TacticalBrowseMode>(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hangar keeps a draft on back and blocks an invalid local name', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('units-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Add local unit'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.byKey(const Key('hangar-card-kaptan.vehicle.fixed-wing.local')),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('PREPARE UNIT'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final name = find.byKey(const Key('local-unit-name-field'));
|
||||||
|
await tester.tap(name);
|
||||||
|
await tester.enterText(name, ' ');
|
||||||
|
await tester.tap(find.text('ADD TO LOCAL SCENE'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.text('Enter a local unit name of up to 32 characters.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
final scope = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
);
|
||||||
|
expect(scope.read(vehicleWorkspaceProvider).units, isEmpty);
|
||||||
|
|
||||||
|
await tester.tap(find.text('BACK TO HANGAR'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('FIXED-WING SELECTED'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
scope.read(tacticalControllerProvider).unitDraft?.definition.name,
|
||||||
|
'Fixed-wing',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hangar supports arrow selection and Escape focus return', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('units-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Add local unit'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final scope = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
scope.read(tacticalControllerProvider).unitDraft?.definition.name,
|
||||||
|
'Fixed-wing',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('UNIT HANGAR'), findsNothing);
|
||||||
|
final trigger = find.descendant(
|
||||||
|
of: find.byKey(const Key('units-menu')),
|
||||||
|
matching: find.byType(TextButton),
|
||||||
|
);
|
||||||
|
expect(tester.widget<TextButton>(trigger).focusNode?.hasFocus, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hangar and setup remain usable at 200 percent text scale', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1440, 960));
|
||||||
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
|
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
|
||||||
addTearDown(() {
|
addTearDown(() {
|
||||||
tester.binding.setSurfaceSize(null);
|
tester.binding.setSurfaceSize(null);
|
||||||
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
|
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
|
||||||
});
|
});
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('OBSERVATION ONLY'), findsOneWidget);
|
final scope = ProviderScope.containerOf(
|
||||||
expect(find.text('Replay ended 12:00:06Z'), findsNothing);
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
);
|
||||||
|
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||||
|
controller.showUnitHangar();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
|
||||||
|
controller.chooseUnitDefinition(
|
||||||
|
supportedVehicleDefinitions.first,
|
||||||
|
displayName: 'QUADCOPTER-01',
|
||||||
|
);
|
||||||
|
controller.beginUnitSetup();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('UNIT SETUP'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('local-unit-name-field')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hangar stays available in the short desktop arrangement', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 760));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final scope = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
);
|
||||||
|
scope.read(tacticalControllerProvider.notifier).showUnitHangar();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('UNIT HANGAR'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('unit-hangar-strip')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('secondary click cancels the pencil cursor', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('cursor-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Pencil cursor'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||||
|
|
||||||
|
final pointer = TestPointer(42, PointerDeviceKind.mouse);
|
||||||
|
await tester.sendEventToBinding(
|
||||||
|
pointer.down(const Offset(700, 520), buttons: kSecondaryButton),
|
||||||
|
);
|
||||||
|
await tester.sendEventToBinding(pointer.up());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('PENCIL CURSOR'), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('keyboard pencil keeps a visible draft before finishing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1600, 960));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('cursor-menu')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Pencil cursor'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
for (var index = 0; index < 3; index++) {
|
||||||
|
await tester.tap(find.text('ADD LOCAL POINT'));
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||||
|
}
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('LOCAL POINTS: 3'), findsOneWidget);
|
||||||
|
expect(find.text('FINISH DRAWING'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('FINISH DRAWING'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('compact command dashboard remains usable without overflow', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(480, 800));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('command dashboard handles 200 percent text scale', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1440, 960));
|
||||||
|
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.binding.setSurfaceSize(null);
|
||||||
|
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
|
||||||
|
});
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('pencil toolbar reflows at 200 percent text scale', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1440, 960));
|
||||||
|
tester.binding.platformDispatcher.textScaleFactorTestValue = 2;
|
||||||
|
addTearDown(() {
|
||||||
|
tester.binding.setSurfaceSize(null);
|
||||||
|
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
|
||||||
|
});
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(TacticalCommandPage)),
|
||||||
|
).read(tacticalControllerProvider.notifier).beginPencil();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('PENCIL CURSOR'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('pencil-thickness')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('short desktop window uses the non-overlapping command layout', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(1366, 768));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
await tester.pumpWidget(const GroundSuiteApp());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user