feat(ground-suite): add tactical command workspace

This commit is contained in:
Onur Özkan committed 2026-08-13 20:04:33 +03:00
1 parent 00a3a9f908
commit 35e0fd8741
10 files changed
+5430 -869

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

+7 -9
View File
@@ -3,20 +3,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app_shell.dart';
import 'theme.dart';
import 'theme_mode_controller.dart';
class GroundSuiteApp extends ConsumerWidget {
class GroundSuiteApp extends StatelessWidget {
const GroundSuiteApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
Widget build(BuildContext context) => ProviderScope(
child: MaterialApp(
title: 'Ground Suite',
debugShowCheckedModeBanner: false,
theme: groundSuiteLightTheme(),
darkTheme: groundSuiteDarkTheme(),
themeMode: ref.watch(themeModeProvider),
theme: groundSuiteDarkTheme(),
themeMode: ThemeMode.dark,
home: const AppShell(),
);
}
),
);
}
+38 -408
View File
@@ -1,14 +1,14 @@
import 'package:flutter/material.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/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 {
const AppShell({super.key});
@@ -17,425 +17,55 @@ class AppShell extends ConsumerStatefulWidget {
}
class _AppShellState extends ConsumerState<AppShell> {
_Destination _destination = _Destination.vehicle;
_ApplicationSurface _surface = _ApplicationSurface.tactical;
@override
Widget build(BuildContext context) {
final state = ref.watch(observabilityControllerProvider);
const pages = <Widget>[OverviewPage(), DiagnosticsPage()];
final observation = ref.watch(observabilityControllerProvider);
return Scaffold(
body: AppCanvas(
child: SafeArea(
child: Stack(
children: [
LayoutBuilder(
builder: (context, constraints) {
final textScale =
MediaQuery.textScalerOf(context).scale(14) / 14;
final compact =
constraints.maxWidth < 960 || textScale > 1.25;
final workspace = _Workspace(
state: state,
selectedIndex: _destination.index,
pages: pages,
compact: compact,
);
return compact
? _CompactShell(
selectedIndex: _destination.index,
onSelected: _selectDestination,
workspace: workspace,
)
: _DesktopShell(
selectedIndex: _destination.index,
onSelected: _selectDestination,
workspace: workspace,
);
},
),
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,
body: Stack(
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),
switch (_surface) {
_ApplicationSurface.tactical => TacticalCommandPage(
sourceLabel: observation.sourceLabel,
sourceStatus: observation.status.label,
onOpenEventStream: () =>
setState(() => _surface = _ApplicationSurface.eventStream),
),
_ApplicationSurface.eventStream => _EventStreamWorkspace(
onReturnToTactical: () =>
setState(() => _surface = _ApplicationSurface.tactical),
),
},
Semantics(
liveRegion: true,
label: observation.accessibilityAnnouncement ?? '',
child: const SizedBox.shrink(),
),
],
),
);
}
}
class _CompactShell extends StatelessWidget {
const _CompactShell({
required this.selectedIndex,
required this.onSelected,
required this.workspace,
});
class _EventStreamWorkspace extends StatelessWidget {
const _EventStreamWorkspace({required this.onReturnToTactical});
final int selectedIndex;
final ValueChanged<int> onSelected;
final Widget workspace;
final VoidCallback onReturnToTactical;
@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,
),
),
],
),
Widget build(BuildContext context) => Stack(
children: [
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'),
),
),
);
}
}
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,
label: message,
child: const SizedBox.square(dimension: 1),
);
}
String _appearanceLabel(ThemeMode mode) => switch (mode) {
ThemeMode.system => 'System default',
ThemeMode.light => 'Light',
ThemeMode.dark => 'Dark',
};
IconData _appearanceIcon(ThemeMode mode) => switch (mode) {
ThemeMode.system => Icons.brightness_auto_outlined,
ThemeMode.light => Icons.light_mode_outlined,
ThemeMode.dark => Icons.dark_mode_outlined,
};
String _shortTimestamp(DateTime value) =>
'${value.hour.toString().padLeft(2, '0')}:'
'${value.minute.toString().padLeft(2, '0')}:'
'${value.second.toString().padLeft(2, '0')}Z';
+57 -104
View File
@@ -1,117 +1,70 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// A restrained green accent, used sparingly against neutral surfaces.
const accentGreen = Color(0xff36d681);
/// Ground Suite's single visual language: a dark tactical operations deck.
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(
brightness: Brightness.light,
seed: const Color(0xff087a3b),
surface: const Color(0xfff3f6f3),
onSurface: const Color(0xff142019),
outline: const Color(0xffc2cec4),
);
const tacticalBlack = Color(0xFF000000);
const tacticalTransparent = Color(0x00000000);
ThemeData groundSuiteDarkTheme() => _theme(
brightness: Brightness.dark,
seed: const Color(0xff36d681),
surface: const Color(0xff080b09),
onSurface: const Color(0xffe8f0ea),
outline: const Color(0xff2a382e),
);
ThemeData groundSuiteDarkTheme() {
final base = ThemeData.dark(useMaterial3: true);
final interTextTheme = GoogleFonts.interTextTheme(
base.textTheme,
).apply(bodyColor: textPrimary, displayColor: textPrimary);
ThemeData _theme({
required Brightness brightness,
required Color seed,
required Color surface,
required Color onSurface,
required Color outline,
}) {
final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: brightness)
.copyWith(
primary: seed,
onPrimary: brightness == Brightness.dark
? const Color(0xff031207)
: Colors.white,
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,
return base.copyWith(
scaffoldBackgroundColor: tacticalBlack,
cardColor: panelBackground,
colorScheme: const ColorScheme.dark(
primary: accentMint,
onPrimary: tacticalBlack,
secondary: accentOrange,
onSecondary: tacticalBlack,
surface: panelBackground,
onSurface: textPrimary,
error: statusRed,
onError: textPrimary,
outline: panelBorder,
),
textTheme: ThemeData(brightness: brightness).textTheme.copyWith(
headlineMedium: TextStyle(
color: onSurface,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
),
titleLarge: TextStyle(color: onSurface, fontWeight: FontWeight.w700),
titleMedium: TextStyle(color: onSurface, fontWeight: FontWeight.w600),
bodyMedium: TextStyle(color: onSurface),
bodySmall: TextStyle(
color: brightness == Brightness.dark
? const Color(0xffa8b5ac)
: const Color(0xff546258),
textTheme: interTextTheme.copyWith(
displayLarge: interTextTheme.displayLarge?.copyWith(color: textPrimary),
displayMedium: interTextTheme.displayMedium?.copyWith(color: textPrimary),
headlineLarge: interTextTheme.headlineLarge?.copyWith(color: textPrimary),
headlineMedium: interTextTheme.headlineMedium?.copyWith(
color: textPrimary,
),
headlineSmall: interTextTheme.headlineSmall?.copyWith(color: textPrimary),
titleLarge: interTextTheme.titleLarge?.copyWith(color: textPrimary),
titleMedium: interTextTheme.titleMedium?.copyWith(color: textPrimary),
titleSmall: interTextTheme.titleSmall?.copyWith(color: textPrimary),
bodyLarge: interTextTheme.bodyLarge?.copyWith(color: textPrimary),
bodyMedium: interTextTheme.bodyMedium?.copyWith(color: textPrimary),
bodySmall: interTextTheme.bodySmall?.copyWith(color: textSecondary),
labelLarge: interTextTheme.labelLarge?.copyWith(color: textPrimary),
labelMedium: interTextTheme.labelMedium?.copyWith(color: textSecondary),
labelSmall: interTextTheme.labelSmall?.copyWith(color: textSecondary),
),
dividerTheme: DividerThemeData(color: outline.withValues(alpha: 0.72)),
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(
backgroundColor: scheme.surfaceContainer.withValues(alpha: 0.72),
selectedColor: seed.withValues(
alpha: brightness == Brightness.dark ? 0.22 : 0.16,
),
side: BorderSide(color: outline.withValues(alpha: 0.75)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
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),
),
backgroundColor: tacticalTransparent,
selectedColor: accentMint.withValues(alpha: .1),
side: const BorderSide(color: panelBorder),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
labelStyle: GoogleFonts.inter(color: textSecondary),
secondaryLabelStyle: GoogleFonts.inter(color: textPrimary),
),
);
}
@@ -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 -2
View File
@@ -1,8 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/app.dart';
void main() {
runApp(const ProviderScope(child: GroundSuiteApp()));
runApp(const GroundSuiteApp());
}
@@ -131,7 +131,7 @@ class AppCanvas extends StatelessWidget {
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
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);
return ColoredBox(
+632 -45
View File
@@ -1,96 +1,683 @@
import 'package:flutter/gestures.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:ground_suite/app/app.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() {
testWidgets('replay workspace keeps diagnostics distinct and read-only', (
testWidgets('desktop command dashboard opens the grouped tactical controls', (
tester,
) 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();
final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
expect(app.themeMode, ThemeMode.system);
expect(app.theme?.brightness, Brightness.light);
expect(app.darkTheme?.brightness, Brightness.dark);
expect(find.byType(FrostedChrome), findsAtLeastNWidgets(1));
expect(find.text('OBSERVATION ONLY'), findsOneWidget);
expect(find.text('Replay fixture'), findsAtLeastNWidgets(1));
expect(find.text('LAST REPORTED HEARTBEAT'), findsOneWidget);
expect(app.themeMode, ThemeMode.dark);
expect(find.text('TACTICAL COMMAND'), findsOneWidget);
expect(find.text('SIMULATED CAMERA CONCEPT'), findsOneWidget);
expect(find.text('DEMO-1021'), findsAtLeastNWidgets(1));
expect(find.byKey(const Key('units-menu')), findsOneWidget);
expect(find.byKey(const Key('cursor-menu')), 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.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();
final context = tester.element(find.byType(AppShell));
expect(
tester.widget<MaterialApp>(find.byType(MaterialApp)).themeMode,
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();
expect(find.text('Event timeline'), findsOneWidget);
await tester.tap(find.text('Errors 1'));
await tester.tap(find.byKey(const Key('unit-card-DEMO-1023')));
await tester.pumpAndSettle();
expect(
find.text('kaptan_drivers::lidar_lite_v3::error::Error::TransferFault'),
find.text('LOCAL VISUAL PREVIEW · NO VEHICLE ACTION'),
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();
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(
'compact desktop layout uses bottom navigation without overflow',
'map deselection and hidden demo overlays never select invisibly',
(tester) async {
await tester.binding.setSurfaceSize(const Size(480, 800));
await tester.binding.setSurfaceSize(const Size(1600, 960));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
expect(find.byKey(const Key('compact-navigation')), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const Key('map-unit-DEMO-1023')));
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 {
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', (
testWidgets('selection cursor ends pencil mode and restores selection', (
tester,
) 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;
addTearDown(() {
tester.binding.setSurfaceSize(null);
tester.binding.platformDispatcher.clearTextScaleFactorTestValue();
});
await tester.pumpWidget(const ProviderScope(child: GroundSuiteApp()));
await tester.pumpWidget(const GroundSuiteApp());
await tester.pumpAndSettle();
expect(find.text('OBSERVATION ONLY'), findsOneWidget);
expect(find.text('Replay ended 12:00:06Z'), findsNothing);
final scope = ProviderScope.containerOf(
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);
});
}