diff --git a/ground-suite/assets/test.png b/ground-suite/assets/test.png new file mode 100644 index 0000000..6e2ea1a Binary files /dev/null and b/ground-suite/assets/test.png differ diff --git a/ground-suite/lib/app/app.dart b/ground-suite/lib/app/app.dart index 4a0cd03..08f1468 100644 --- a/ground-suite/lib/app/app.dart +++ b/ground-suite/lib/app/app.dart @@ -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(), - ); - } + ), + ); } diff --git a/ground-suite/lib/app/app_shell.dart b/ground-suite/lib/app/app_shell.dart index 6cea746..b733543 100644 --- a/ground-suite/lib/app/app_shell.dart +++ b/ground-suite/lib/app/app_shell.dart @@ -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 { - _Destination _destination = _Destination.vehicle; + _ApplicationSurface _surface = _ApplicationSurface.tactical; @override Widget build(BuildContext context) { - final state = ref.watch(observabilityControllerProvider); - const pages = [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 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 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 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( - 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 _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'; diff --git a/ground-suite/lib/app/theme.dart b/ground-suite/lib/app/theme.dart index fdaa9de..096df14 100644 --- a/ground-suite/lib/app/theme.dart +++ b/ground-suite/lib/app/theme.dart @@ -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), ), ); } diff --git a/ground-suite/lib/app/theme_mode_controller.dart b/ground-suite/lib/app/theme_mode_controller.dart deleted file mode 100644 index 594212c..0000000 --- a/ground-suite/lib/app/theme_mode_controller.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -final themeModeProvider = NotifierProvider( - 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 { - @override - ThemeMode build() => ThemeMode.system; - - void select(ThemeMode mode) => state = mode; -} diff --git a/ground-suite/lib/features/command/presentation/tactical_command_page.dart b/ground-suite/lib/features/command/presentation/tactical_command_page.dart new file mode 100644 index 0000000..ce5c9f8 --- /dev/null +++ b/ground-suite/lib/features/command/presentation/tactical_command_page.dart @@ -0,0 +1,4694 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/gestures.dart' show kPrimaryButton, kSecondaryButton; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../app/theme.dart'; +import '../../fleet/domain/fleet_unit.dart'; +import '../../vehicle_workspace/domain/vehicle_catalog.dart'; +import '../../vehicle_workspace/domain/vehicle_definition.dart'; +import '../../vehicle_workspace/presentation/vehicle_workspace_controller.dart'; +import 'tactical_controller.dart'; + +const _fpvPlaceholder = + 'https://images.unsplash.com/photo-1470770841072-f978cf4d019e' + '?auto=format&fit=crop&w=1600&q=85'; + +const _fleet = <_DroneData>[ + _DroneData('DEMO-1021', '41.4 km/h', '547 m', 'On patrol', statusGreen, .78), + _DroneData('DEMO-1023', '39.7 km/h', '511 m', 'On route', statusGreen, .64), + _DroneData( + 'DEMO-1022', + '43.5 km/h', + '611 m', + 'Monitoring', + accentOrange, + .92, + ), + _DroneData('DEMO-1024', '—', '—', 'Offline', statusRed, .18), +]; + +const _unitMapAnchors = { + 'DEMO-1021': Alignment(.14, .18), + 'DEMO-1023': Alignment(-.08, .46), + 'DEMO-1022': Alignment(.46, -.22), + 'DEMO-1024': Alignment(-.42, -.32), +}; + +String _modeLabel(TacticalMode mode) => switch (mode) { + TacticalPencilMode() => 'PENCIL', + TacticalMarkerMode() => 'MARKER', + _ => 'SELECTION', +}; + +/// The high-density tactical surface for Ground Suite. +/// +/// The current scene values are local data only; they are deliberately kept +/// separate from the vehicle data port until a reviewed live integration lands. +class TacticalCommandPage extends ConsumerStatefulWidget { + const TacticalCommandPage({ + required this.sourceLabel, + required this.sourceStatus, + required this.onOpenEventStream, + super.key, + }); + + final String sourceLabel; + final String sourceStatus; + final VoidCallback onOpenEventStream; + + @override + ConsumerState createState() => + _TacticalCommandPageState(); +} + +class _TacticalCommandPageState extends ConsumerState { + final FocusNode _fallbackFocus = FocusNode(debugLabel: 'tactical-workspace'); + final FocusNode _unitsTriggerFocus = FocusNode(debugLabel: 'units-trigger'); + FocusNode? _toolReturnFocus; + + @override + void dispose() { + _fallbackFocus.dispose(); + _unitsTriggerFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final mode = ref.watch( + tacticalControllerProvider.select((state) => state.mode), + ); + return Focus( + focusNode: _fallbackFocus, + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.escape) { + final tactical = ref.read(tacticalControllerProvider); + if (mode is! TacticalBrowseMode) { + _handleAction(_TacticalAction.cancel); + return KeyEventResult.handled; + } + if (tactical.panel == TacticalPanel.unitSetup) { + if (tactical.unitDraft != null) { + ref + .read(tacticalControllerProvider.notifier) + .returnToUnitHangar(); + } else { + _closeUnitHangar(); + } + return KeyEventResult.handled; + } + if (tactical.panel == TacticalPanel.unitHangar) { + _closeUnitHangar(); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; + // The desktop deck relies on fixed visual regions. A short desktop window + // receives the scrollable compact arrangement instead of overlapping the + // map controls and the camera panel. + final compact = + constraints.maxWidth < 1120 || + constraints.maxHeight < 820 || + textScale >= 1.25; + final editing = mode is! TacticalBrowseMode; + final content = Stack( + fit: StackFit.expand, + children: [ + const _SatelliteMapBackground(), + const Positioned.fill(child: _MapAtmosphere()), + Positioned.fill( + child: _TacticalMapLayer( + onUnitSelected: _selectUnit, + onSelectionRect: _selectUnitsInRect, + ), + ), + _TacticalDeckLayer( + compact: compact, + width: constraints.maxWidth, + height: constraints.maxHeight, + onUnitSelected: _selectUnit, + onAction: _handleAction, + sourceLabel: widget.sourceLabel, + sourceStatus: widget.sourceStatus, + onOpenEventStream: widget.onOpenEventStream, + unitsTriggerFocus: _unitsTriggerFocus, + onCloseHangar: _closeUnitHangar, + ), + ], + ); + return Stack( + fit: StackFit.expand, + children: [ + ExcludeSemantics( + excluding: editing, + child: ExcludeFocus( + excluding: editing, + child: IgnorePointer(ignoring: editing, child: content), + ), + ), + if (editing) + Positioned.fill( + child: _TacticalEditLayer( + onAction: _handleAction, + onStrokeComplete: _completeSceneStroke, + onNudgeCursor: _nudgeSceneCursor, + onCommitCursor: _commitSceneCursor, + onFinishKeyboardPencil: _finishKeyboardPencil, + onConfigurePencil: _configurePencil, + onConfigureMarker: _configureMarker, + onCommitMarkerName: _commitMarkerName, + ), + ), + ], + ); + }, + ), + ); + } + + void _closeUnitHangar() { + ref.read(tacticalControllerProvider.notifier).closeUnitHangar(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _unitsTriggerFocus.requestFocus(); + } + }); + } + + void _selectUnit(String unitId) { + // A draft owns an explicit Close/Back boundary. Map selection must never + // silently dispose a typed local name or selected category. + if (ref.read(tacticalControllerProvider).unitDraft != null) { + return; + } + if (unitId.isEmpty) { + ref.read(tacticalControllerProvider.notifier).clearSelection(); + return; + } + final shiftPressed = HardwareKeyboard.instance.logicalKeysPressed.any( + (key) => + key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight, + ); + ref + .read(tacticalControllerProvider.notifier) + .selectUnit(unitId, toggle: shiftPressed); + } + + void _selectUnitsInRect(LocalScenePoint start, LocalScenePoint end) { + // Like a direct marker click, a drag-selection cannot quietly close an + // in-progress local setup. Close/Back is the draft's only exit boundary. + if (ref.read(tacticalControllerProvider).unitDraft != null) { + return; + } + final left = math.min(start.x, end.x); + final right = math.max(start.x, end.x); + final top = math.min(start.y, end.y); + final bottom = math.max(start.y, end.y); + bool contains(LocalScenePoint point) => + point.x >= left && + point.x <= right && + point.y >= top && + point.y <= bottom; + final showDemoUnits = ref + .read(tacticalControllerProvider) + .overlays + .contains(TacticalMapOverlay.drones); + final demoIds = (showDemoUnits ? _fleet : const <_DroneData>[]) + .where( + (unit) => contains( + LocalScenePoint( + x: (_unitMapAnchors[unit.id]!.x + 1) / 2, + y: (_unitMapAnchors[unit.id]!.y + 1) / 2, + ), + ), + ) + .map((unit) => unit.id); + final localIds = ref + .read(vehicleWorkspaceProvider) + .units + .where( + (unit) => contains( + LocalScenePoint(x: unit.localPlacement.x, y: unit.localPlacement.y), + ), + ) + .map((unit) => unit.localUnitId); + ref.read(tacticalControllerProvider.notifier).selectUnits({ + ...demoIds, + ...localIds, + }); + } + + Future _handleAction(_TacticalAction action) async { + final controller = ref.read(tacticalControllerProvider.notifier); + switch (action) { + case _TacticalAction.addUnit: + controller.showUnitHangar(); + case _TacticalAction.openFleet: + controller.showFleetRoster(); + case _TacticalAction.closeFleet: + controller.closePanel(); + case _TacticalAction.openUnitSetup: + final selectedIds = ref + .read(tacticalControllerProvider) + .selectedUnitIds; + final selectedId = selectedIds.length == 1 ? selectedIds.single : null; + if (selectedId != null && + ref + .read(vehicleWorkspaceProvider) + .units + .any((unit) => unit.localUnitId == selectedId)) { + controller.showUnitSetupForSelectedLocalUnit(selectedId); + } + case _TacticalAction.closeUnitHangar: + _closeUnitHangar(); + case _TacticalAction.clearSelection: + controller.clearSelection(); + case _TacticalAction.resetView: + controller.resetLocalView(); + case _TacticalAction.selectTool: + ref.read(tacticalPointerPreviewProvider.notifier).clear(); + controller.cancelMode(); + _restoreToolFocus(); + case _TacticalAction.pencilTool: + _beginTool(controller.beginPencil); + case _TacticalAction.markerTool: + _beginTool(controller.beginMarker); + case _TacticalAction.clearSceneObjects: + if (await _confirmClearLocalOverlays()) { + controller.clearSceneObjects(); + } + case _TacticalAction.removeSelectedSceneObject: + controller.removeSelectedSceneObject(); + case _TacticalAction.cancel: + _cancelActiveTool(); + } + } + + void _beginTool(VoidCallback begin) { + _toolReturnFocus = FocusManager.instance.primaryFocus; + begin(); + } + + void _restoreToolFocus() { + final returnFocus = _toolReturnFocus; + _toolReturnFocus = null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + if (returnFocus?.context != null && + (returnFocus?.canRequestFocus ?? false)) { + returnFocus!.requestFocus(); + } else { + _fallbackFocus.requestFocus(); + } + }); + } + + Future _confirmClearLocalOverlays() async => + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Clear local overlays?'), + content: const Text( + 'This removes all session-only local pencil drawings and markers. It does not affect any vehicle.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Keep local scene'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Clear drawings and markers'), + ), + ], + ), + ) ?? + false; + + void _completeSceneStroke(List points) { + if (ref + .read(tacticalControllerProvider.notifier) + .commitPencilStroke(points)) { + ref.read(tacticalPointerPreviewProvider.notifier).clear(); + } + } + + void _nudgeSceneCursor(double dx, double dy) => + ref.read(tacticalPointerPreviewProvider.notifier).nudge(dx, dy); + + void _commitSceneCursor() { + final point = + ref.read(tacticalPointerPreviewProvider).point ?? + const LocalScenePoint(x: .5, y: .5); + final mode = ref.read(tacticalControllerProvider).mode; + final controller = ref.read(tacticalControllerProvider.notifier); + if (mode is TacticalPencilMode) { + controller.addKeyboardPencilPoint(point); + return; + } + if (mode is TacticalMarkerMode) { + controller.chooseMarkerPoint(point); + return; + } + return; + } + + void _finishKeyboardPencil() { + if (ref.read(tacticalControllerProvider.notifier).finishKeyboardPencil()) { + ref.read(tacticalPointerPreviewProvider.notifier).clear(); + } + } + + void _configurePencil({ + double? hue, + double? saturation, + double? brightness, + double? thickness, + }) => ref + .read(tacticalControllerProvider.notifier) + .configurePencil( + hue: hue, + saturation: saturation, + brightness: brightness, + thickness: thickness, + ); + + void _configureMarker({ + double? hue, + double? saturation, + double? brightness, + }) => ref + .read(tacticalControllerProvider.notifier) + .configureMarker( + hue: hue, + saturation: saturation, + brightness: brightness, + ); + + void _commitMarkerName(String value) => + ref.read(tacticalControllerProvider.notifier).commitMarkerName(value); + + void _cancelActiveTool() { + ref.read(tacticalPointerPreviewProvider.notifier).clear(); + if (ref.read(tacticalControllerProvider.notifier).cancelActiveTool()) { + _restoreToolFocus(); + } + } +} + +/// Owns only map-derived state. The base map and tactical chrome are outside +/// this consumer, so selection/annotation changes do not rebuild either. +class _TacticalMapLayer extends ConsumerWidget { + const _TacticalMapLayer({ + required this.onUnitSelected, + required this.onSelectionRect, + }); + + final ValueChanged onUnitSelected; + final void Function(LocalScenePoint start, LocalScenePoint end) + onSelectionRect; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Keep local-name typing in the hangar from rebuilding this renderer. The + // selected record consists only of map-visible state and retains identity + // when a panel draft changes. + final tactical = ref.watch( + tacticalControllerProvider.select( + (state) => ( + selectedUnitIds: state.selectedUnitIds, + mode: state.mode, + drawings: state.drawings, + markers: state.markers, + overlays: state.overlays, + ), + ), + ); + final localUnits = ref.watch( + vehicleWorkspaceProvider.select((workspace) => workspace.units), + ); + return _DemoMapOverlays( + layers: _layerNames(tactical.overlays), + drawings: tactical.drawings, + markers: tactical.markers, + draftPencil: tactical.mode is TacticalPencilMode + ? tactical.mode as TacticalPencilMode + : null, + draftMarker: tactical.mode is TacticalMarkerMode + ? tactical.mode as TacticalMarkerMode + : null, + localUnits: localUnits, + selectedUnitIds: tactical.selectedUnitIds, + onUnitSelected: onUnitSelected, + onSelectionRect: onSelectionRect, + onMarkerSelected: ref + .read(tacticalControllerProvider.notifier) + .selectMarker, + ); + } +} + +/// Owns the dynamic deck data separately from the map renderer. Individual +/// scene visuals are isolated by RepaintBoundary and their own consumers. +class _TacticalDeckLayer extends ConsumerWidget { + const _TacticalDeckLayer({ + required this.compact, + required this.width, + required this.height, + required this.onUnitSelected, + required this.onAction, + required this.sourceLabel, + required this.sourceStatus, + required this.onOpenEventStream, + required this.unitsTriggerFocus, + required this.onCloseHangar, + }); + + final bool compact; + final double width; + final double height; + final ValueChanged onUnitSelected; + final ValueChanged<_TacticalAction> onAction; + final String sourceLabel; + final String sourceStatus; + final VoidCallback onOpenEventStream; + final FocusNode unitsTriggerFocus; + final VoidCallback onCloseHangar; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tactical = ref.watch(tacticalSceneRenderStateProvider); + final workspaceUnits = ref.watch( + vehicleWorkspaceProvider.select((workspace) => workspace.units), + ); + final selectedId = tactical.selectedUnitIds.length == 1 + ? tactical.selectedUnitIds.single + : null; + _DroneData? selectedUnit; + if (selectedId != null) { + for (final unit in _fleet) { + if (unit.id == selectedId) { + selectedUnit = unit; + break; + } + } + } + final layers = _layerNames(tactical.overlays); + if (compact) { + return _CompactCommandDeck( + layers: layers, + onLayerChanged: (label, visible) => ref + .read(tacticalControllerProvider.notifier) + .toggleOverlay(_overlayForLabel(label), visible), + selectedUnit: selectedUnit, + selectedUnitIds: tactical.selectedUnitIds, + onUnitSelected: onUnitSelected, + tactical: tactical, + workspaceUnits: workspaceUnits, + onAction: onAction, + sourceLabel: sourceLabel, + sourceStatus: sourceStatus, + onOpenEventStream: onOpenEventStream, + unitsTriggerFocus: unitsTriggerFocus, + onCloseHangar: onCloseHangar, + ); + } + return _DesktopCommandDeck( + width: width, + height: height, + layers: layers, + onLayerChanged: (label, visible) => ref + .read(tacticalControllerProvider.notifier) + .toggleOverlay(_overlayForLabel(label), visible), + selectedUnit: selectedUnit, + selectedUnitIds: tactical.selectedUnitIds, + onUnitSelected: onUnitSelected, + tactical: tactical, + workspaceUnits: workspaceUnits, + onAction: onAction, + sourceLabel: sourceLabel, + sourceStatus: sourceStatus, + onOpenEventStream: onOpenEventStream, + unitsTriggerFocus: unitsTriggerFocus, + onCloseHangar: onCloseHangar, + ); + } +} + +/// The deck's rendering contract excludes the uncommitted hangar draft. This +/// lets a name-field update rebuild only [_UnitHangarDock], not FPV, chrome, +/// map controls, or the selection console. Fields are compared by identity: +/// the tactical reducer deliberately preserves unchanged immutable records. +class TacticalSceneRenderState { + const TacticalSceneRenderState({ + required this.selectedUnitIds, + required this.mode, + required this.panel, + required this.drawings, + required this.markers, + required this.selectedDrawingId, + required this.selectedMarkerId, + required this.overlays, + }); + + factory TacticalSceneRenderState.fromUiState(TacticalUiState state) => + TacticalSceneRenderState( + selectedUnitIds: state.selectedUnitIds, + mode: state.mode, + panel: state.panel, + drawings: state.drawings, + markers: state.markers, + selectedDrawingId: state.selectedDrawingId, + selectedMarkerId: state.selectedMarkerId, + overlays: state.overlays, + ); + + final Set selectedUnitIds; + final TacticalMode mode; + final TacticalPanel? panel; + final List drawings; + final List markers; + final String? selectedDrawingId; + final String? selectedMarkerId; + final Set overlays; + + @override + bool operator ==(Object other) => + other is TacticalSceneRenderState && + identical(selectedUnitIds, other.selectedUnitIds) && + identical(mode, other.mode) && + panel == other.panel && + identical(drawings, other.drawings) && + identical(markers, other.markers) && + selectedDrawingId == other.selectedDrawingId && + selectedMarkerId == other.selectedMarkerId && + identical(overlays, other.overlays); + + @override + int get hashCode => Object.hash( + identityHashCode(selectedUnitIds), + identityHashCode(mode), + panel, + identityHashCode(drawings), + identityHashCode(markers), + selectedDrawingId, + selectedMarkerId, + identityHashCode(overlays), + ); +} + +/// Projection consumed by static tactical chrome. It intentionally omits the +/// uncommitted [UnitCreationDraft], enabling local setup edits without a +/// map/deck rebuild. +final tacticalSceneRenderStateProvider = Provider( + (ref) => TacticalSceneRenderState.fromUiState( + ref.watch(tacticalControllerProvider), + ), +); + +class _TacticalEditLayer extends ConsumerWidget { + const _TacticalEditLayer({ + required this.onAction, + required this.onStrokeComplete, + required this.onNudgeCursor, + required this.onCommitCursor, + required this.onFinishKeyboardPencil, + required this.onConfigurePencil, + required this.onConfigureMarker, + required this.onCommitMarkerName, + }); + + final ValueChanged<_TacticalAction> onAction; + final ValueChanged> onStrokeComplete; + final void Function(double dx, double dy) onNudgeCursor; + final VoidCallback onCommitCursor; + final VoidCallback onFinishKeyboardPencil; + final void Function({ + double? hue, + double? saturation, + double? brightness, + double? thickness, + }) + onConfigurePencil; + final void Function({double? hue, double? saturation, double? brightness}) + onConfigureMarker; + final ValueChanged onCommitMarkerName; + + @override + Widget build(BuildContext context, WidgetRef ref) => _TacticalEditOverlay( + tactical: ref.watch(tacticalControllerProvider), + onAction: onAction, + onStrokeComplete: onStrokeComplete, + cursor: ref.watch(tacticalPointerPreviewProvider).point, + onNudgeCursor: onNudgeCursor, + onCommitCursor: onCommitCursor, + onFinishKeyboardPencil: onFinishKeyboardPencil, + onConfigurePencil: onConfigurePencil, + onConfigureMarker: onConfigureMarker, + onCommitMarkerName: onCommitMarkerName, + ); +} + +enum _TacticalAction { + addUnit, + openFleet, + closeFleet, + openUnitSetup, + closeUnitHangar, + clearSelection, + resetView, + selectTool, + pencilTool, + markerTool, + clearSceneObjects, + removeSelectedSceneObject, + cancel, +} + +class _NudgeSceneCursorIntent extends Intent { + const _NudgeSceneCursorIntent(this.dx, this.dy); + + final double dx; + final double dy; +} + +class _CommitSceneCursorIntent extends Intent { + const _CommitSceneCursorIntent(); +} + +Set _layerNames(Set overlays) => { + if (overlays.contains(TacticalMapOverlay.drones)) 'Drones', +}; + +TacticalMapOverlay _overlayForLabel(String label) => switch (label) { + 'Drones' => TacticalMapOverlay.drones, + _ => throw ArgumentError.value(label, 'label', 'Unknown tactical overlay'), +}; + +class _SatelliteMapBackground extends StatelessWidget { + const _SatelliteMapBackground(); + + @override + Widget build(BuildContext context) => ExcludeSemantics( + child: Image.asset( + 'assets/test.png', + fit: BoxFit.cover, + cacheWidth: 1920, + filterQuality: FilterQuality.medium, + errorBuilder: (context, error, stackTrace) => const ColoredBox( + color: tacticalBlack, + child: Center( + child: _UnavailableVisual( + icon: Icons.public_off_outlined, + label: 'DEMO MAP VISUAL UNAVAILABLE', + ), + ), + ), + ), + ); +} + +class _MapAtmosphere extends StatelessWidget { + const _MapAtmosphere(); + + @override + Widget build(BuildContext context) => IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + tacticalBlack.withValues(alpha: .36), + tacticalBlack.withValues(alpha: .72), + ], + ), + ), + child: const CustomPaint(painter: _MapGridPainter()), + ), + ); +} + +class _DemoDisclosureBanner extends StatelessWidget { + const _DemoDisclosureBanner(); + + @override + Widget build(BuildContext context) => Semantics( + container: true, + label: 'Local scene data. No vehicle link. Command authority unavailable.', + child: _TacticalGlass( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + borderRadius: BorderRadius.circular(12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.science_outlined, color: accentOrange, size: 16), + const SizedBox(width: 8), + Flexible( + child: _HudLabel( + 'LOCAL SCENE DATA · NO VEHICLE LINK · COMMAND AUTHORITY UNAVAILABLE', + color: accentOrange, + ), + ), + ], + ), + ), + ); +} + +class _UnavailableVisual extends StatelessWidget { + const _UnavailableVisual({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: textSecondary), + const SizedBox(height: 8), + _HudLabel(label, color: textSecondary), + ], + ); +} + +class _DemoMapOverlays extends ConsumerWidget { + const _DemoMapOverlays({ + required this.layers, + required this.drawings, + required this.markers, + required this.draftPencil, + required this.draftMarker, + required this.localUnits, + required this.selectedUnitIds, + required this.onUnitSelected, + required this.onSelectionRect, + required this.onMarkerSelected, + }); + + final Set layers; + final List drawings; + final List markers; + final TacticalPencilMode? draftPencil; + final TacticalMarkerMode? draftMarker; + final List localUnits; + final Set selectedUnitIds; + final ValueChanged onUnitSelected; + final void Function(LocalScenePoint start, LocalScenePoint end) + onSelectionRect; + final ValueChanged onMarkerSelected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pointerPreview = ref.watch(tacticalPointerPreviewProvider); + return Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + child: ExcludeSemantics( + child: RepaintBoundary( + child: CustomPaint( + key: const Key('tactical-map-overlay-painter'), + painter: _DemoMapOverlayPainter( + drawings: drawings, + markers: markers, + draftPencil: draftPencil, + draftMarker: draftMarker, + pointerPreview: pointerPreview.point, + previewStroke: pointerPreview.stroke, + ), + ), + ), + ), + ), + ), + Positioned.fill(child: _MapSelectionInput(onComplete: onSelectionRect)), + if (layers.contains('Drones')) + for (final unit in _fleet) + Align( + alignment: _unitMapAnchors[unit.id]!, + child: _DemoUnitMapMarker( + unit: unit, + selected: selectedUnitIds.contains(unit.id), + onTap: () => onUnitSelected(unit.id), + ), + ), + for (final unit in localUnits) + Align( + alignment: Alignment( + unit.localPlacement.x * 2 - 1, + unit.localPlacement.y * 2 - 1, + ), + child: _LocalUnitMapMarker( + unit: unit, + selected: selectedUnitIds.contains(unit.localUnitId), + onTap: () => onUnitSelected(unit.localUnitId), + ), + ), + for (final marker in markers) + Align( + alignment: Alignment( + marker.point.x * 2 - 1, + marker.point.y * 2 - 1, + ), + child: _LocalSceneMarkerPin( + marker: marker, + onTap: () => onMarkerSelected(marker.id), + ), + ), + ], + ); + } +} + +class _LocalSceneMarkerPin extends StatelessWidget { + const _LocalSceneMarkerPin({required this.marker, required this.onTap}); + + final LocalSceneMarker marker; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: 'Local marker ${marker.name}, session-only, not a vehicle location', + child: TextButton.icon( + key: Key('map-marker-${marker.id}'), + onPressed: onTap, + style: TextButton.styleFrom( + foregroundColor: _markerColor( + marker.hue, + marker.saturation, + marker.brightness, + ), + backgroundColor: panelBackground.withValues(alpha: .9), + side: BorderSide( + color: _markerColor(marker.hue, marker.saturation, marker.brightness), + ), + shape: const StadiumBorder(), + ), + icon: const Icon(Icons.location_on_outlined, size: 14), + label: Text( + marker.name, + style: GoogleFonts.inter(fontSize: 8, fontWeight: FontWeight.w800), + ), + ), + ); +} + +/// Pointer-only RTS box selection. Keyboard users have equivalent selection +/// through the roster and focusable map markers. Motion is coalesced to one +/// paint per frame and never enters the tactical state reducer until release. +class _MapSelectionInput extends StatefulWidget { + const _MapSelectionInput({required this.onComplete}); + + final void Function(LocalScenePoint start, LocalScenePoint end) onComplete; + + @override + State<_MapSelectionInput> createState() => _MapSelectionInputState(); +} + +class _MapSelectionInputState extends State<_MapSelectionInput> { + LocalScenePoint? _start; + LocalScenePoint? _current; + LocalScenePoint? _pending; + bool _frameScheduled = false; + + @override + Widget build(BuildContext context) => Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + if (event.buttons != kPrimaryButton) { + return; + } + final point = _pointFor(event.position); + if (point == null) { + return; + } + setState(() { + _start = point; + _current = point; + }); + }, + onPointerMove: (event) { + if (_start == null) { + return; + } + _pending = _pointFor(event.position); + if (_frameScheduled || _pending == null) { + return; + } + _frameScheduled = true; + SchedulerBinding.instance.scheduleFrameCallback((_) { + _frameScheduled = false; + final point = _pending; + _pending = null; + if (!mounted || point == null || _start == null) { + return; + } + setState(() => _current = point); + }); + }, + onPointerUp: (event) { + final start = _start; + final end = _pointFor(event.position); + setState(() { + _start = null; + _current = null; + }); + if (start == null || end == null) { + return; + } + widget.onComplete(start, end); + }, + onPointerCancel: (_) => setState(() { + _start = null; + _current = null; + _pending = null; + }), + child: ExcludeSemantics( + child: RepaintBoundary( + child: CustomPaint( + painter: _SelectionBoxPainter(start: _start, current: _current), + child: const SizedBox.expand(), + ), + ), + ), + ); + + LocalScenePoint? _pointFor(Offset globalPosition) { + final box = context.findRenderObject() as RenderBox?; + if (box == null || box.size.isEmpty) { + return null; + } + final local = box.globalToLocal(globalPosition); + return LocalScenePoint( + x: (local.dx / box.size.width).clamp(0.0, 1.0), + y: (local.dy / box.size.height).clamp(0.0, 1.0), + ); + } +} + +class _SelectionBoxPainter extends CustomPainter { + const _SelectionBoxPainter({required this.start, required this.current}); + + final LocalScenePoint? start; + final LocalScenePoint? current; + + @override + void paint(Canvas canvas, Size size) { + final origin = start; + final end = current; + if (origin == null || end == null) { + return; + } + final rect = Rect.fromPoints( + Offset(origin.x * size.width, origin.y * size.height), + Offset(end.x * size.width, end.y * size.height), + ); + canvas.drawRect(rect, Paint()..color = accentMint.withValues(alpha: .1)); + canvas.drawRect( + rect, + Paint() + ..color = accentMint + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + } + + @override + bool shouldRepaint(covariant _SelectionBoxPainter oldDelegate) => + oldDelegate.start != start || oldDelegate.current != current; +} + +/// A semantic hit target over the decorative local-scene marker. The marker +/// selects only mock scene data; it never binds or commands a vehicle. +class _DemoUnitMapMarker extends StatelessWidget { + const _DemoUnitMapMarker({ + required this.unit, + required this.selected, + required this.onTap, + }); + + final _DroneData unit; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + selected: selected, + label: '${unit.id}, simulated local scene unit, not a vehicle position', + child: TextButton.icon( + key: Key('map-unit-${unit.id}'), + onPressed: onTap, + style: TextButton.styleFrom( + foregroundColor: selected ? tacticalBlack : accentMint, + backgroundColor: selected + ? accentMint + : panelBackground.withValues(alpha: .88), + side: BorderSide(color: selected ? accentMint : panelBorder), + shape: const StadiumBorder(), + ), + icon: const Icon(Icons.flight_outlined, size: 14), + label: Text( + unit.id, + style: GoogleFonts.robotoMono(fontSize: 8, fontWeight: FontWeight.w700), + ), + ), + ); +} + +class _LocalUnitMapMarker extends StatelessWidget { + const _LocalUnitMapMarker({ + required this.unit, + required this.selected, + required this.onTap, + }); + + final FleetUnit unit; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + selected: selected, + label: + '${unit.displayName}, local workspace unit, local layout only, no vehicle position reported', + child: TextButton.icon( + onPressed: onTap, + style: TextButton.styleFrom( + foregroundColor: selected ? tacticalBlack : accentMint, + backgroundColor: selected + ? accentMint + : panelBackground.withValues(alpha: .9), + side: BorderSide(color: selected ? accentMint : panelBorder), + shape: const StadiumBorder(), + ), + icon: const Icon(Icons.flight_outlined, size: 15), + label: Text( + unit.displayName, + style: GoogleFonts.robotoMono(fontSize: 8, fontWeight: FontWeight.w700), + ), + ), + ); +} + +class _TacticalInspector extends StatelessWidget { + const _TacticalInspector({ + required this.selectedUnitId, + required this.selectedDemo, + required this.workspaceUnits, + }); + + final String selectedUnitId; + final _DroneData? selectedDemo; + final List workspaceUnits; + + @override + Widget build(BuildContext context) { + final local = workspaceUnits.cast().firstWhere( + (unit) => unit?.localUnitId == selectedUnitId, + orElse: () => null, + ); + final isLocal = local != null; + final name = local?.displayName ?? selectedDemo?.id ?? selectedUnitId; + return Semantics( + container: true, + label: '$name tactical inspector', + child: _TacticalGlass( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const _HudLabel('SELECTION INSPECTOR'), + const SizedBox(height: 6), + _MonoText( + name, + color: textPrimary, + size: 12, + weight: FontWeight.w700, + ), + const SizedBox(height: 8), + _InspectorFact( + label: 'LOCAL CONTEXT', + value: isLocal + ? 'UNBOUND WORKSPACE UNIT' + : 'SIMULATED LOCAL DATA', + ), + const _InspectorFact( + label: 'REPORTED PLATFORM', + value: 'NOT REPORTED', + ), + const _InspectorFact( + label: 'COMPATIBILITY', + value: 'NOT EVALUATED', + ), + const _InspectorFact( + label: 'WRITE AUTHORITY', + value: 'UNAVAILABLE', + ), + ], + ), + ), + ); + } +} + +class _InspectorFact extends StatelessWidget { + const _InspectorFact({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + children: [ + Expanded(child: _HudLabel(label)), + Flexible(child: _HudLabel(value, color: textPrimary)), + ], + ), + ); +} + +class _DrawingInspector extends StatelessWidget { + const _DrawingInspector({required this.drawing, required this.onDelete}); + + final LocalSceneDrawing drawing; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) => _TacticalGlass( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const _HudLabel('SELECTION INSPECTOR'), + const SizedBox(height: 6), + _MonoText( + 'LOCAL PENCIL DRAWING', + color: _pencilColor( + drawing.hue, + drawing.saturation, + drawing.brightness, + ), + size: 12, + weight: FontWeight.w700, + ), + const SizedBox(height: 8), + const _HudLabel( + 'LOCAL SCENE ANNOTATION · NOT A VEHICLE LOCATION', + color: accentOrange, + ), + const SizedBox(height: 4), + Text( + '${drawing.points.length} stroke point(s) · ${drawing.thickness.toStringAsFixed(0)} px · session only', + ), + const SizedBox(height: 10), + _UnitActionButton( + label: 'REMOVE LOCAL OVERLAY', + icon: Icons.delete_outline, + foreground: textPrimary, + background: tacticalTransparent, + onPressed: onDelete, + ), + ], + ), + ); +} + +class _MarkerInspector extends StatelessWidget { + const _MarkerInspector({required this.marker, required this.onDelete}); + + final LocalSceneMarker marker; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) => _TacticalGlass( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const _HudLabel('SELECTION INSPECTOR'), + const SizedBox(height: 6), + _MonoText( + marker.name, + color: _markerColor(marker.hue, marker.saturation, marker.brightness), + size: 12, + weight: FontWeight.w700, + ), + const SizedBox(height: 8), + const _HudLabel( + 'LOCAL MARKER · NOT A VEHICLE LOCATION', + color: accentOrange, + ), + const SizedBox(height: 4), + const Text('Session only · not sent to a vehicle'), + const SizedBox(height: 10), + _UnitActionButton( + label: 'REMOVE LOCAL MARKER', + icon: Icons.delete_outline, + foreground: textPrimary, + background: tacticalTransparent, + onPressed: onDelete, + ), + ], + ), + ); +} + +class _FleetWorkspaceRoster extends StatelessWidget { + const _FleetWorkspaceRoster({ + required this.workspaceUnits, + required this.tactical, + required this.onSelected, + required this.onClose, + }); + + final List workspaceUnits; + final TacticalSceneRenderState tactical; + final ValueChanged onSelected; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) => _TacticalGlass( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded(child: _HudLabel('LOCAL FLEET WORKSPACE')), + IconButton( + tooltip: 'Close local fleet workspace', + onPressed: onClose, + icon: const Icon(Icons.close, size: 18), + ), + ], + ), + const SizedBox(height: 4), + const Text('Local units only. No vehicle link or authority.'), + const SizedBox(height: 10), + Expanded( + child: ListView.separated( + itemCount: workspaceUnits.length, + separatorBuilder: (context, index) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final unit = workspaceUnits[index]; + final selected = tactical.selectedUnitIds.contains( + unit.localUnitId, + ); + return Semantics( + button: true, + selected: selected, + label: '${unit.displayName}, local workspace unit', + child: OutlinedButton( + onPressed: () => onSelected(unit.localUnitId), + style: OutlinedButton.styleFrom( + alignment: Alignment.centerLeft, + foregroundColor: selected ? accentMint : textPrimary, + side: BorderSide( + color: selected ? accentMint : panelBorder, + ), + ), + child: Text(unit.displayName), + ), + ); + }, + ), + ), + ], + ), + ); +} + +class _DesktopCommandDeck extends StatelessWidget { + const _DesktopCommandDeck({ + required this.width, + required this.height, + required this.layers, + required this.onLayerChanged, + required this.selectedUnit, + required this.selectedUnitIds, + required this.onUnitSelected, + required this.tactical, + required this.workspaceUnits, + required this.onAction, + required this.sourceLabel, + required this.sourceStatus, + required this.onOpenEventStream, + required this.unitsTriggerFocus, + required this.onCloseHangar, + }); + + final double width; + final double height; + final Set layers; + final void Function(String layer, bool enabled) onLayerChanged; + final _DroneData? selectedUnit; + final Set selectedUnitIds; + final ValueChanged onUnitSelected; + final TacticalSceneRenderState tactical; + final List workspaceUnits; + final ValueChanged<_TacticalAction> onAction; + final String sourceLabel; + final String sourceStatus; + final VoidCallback onOpenEventStream; + final FocusNode unitsTriggerFocus; + final VoidCallback onCloseHangar; + + @override + Widget build(BuildContext context) { + final fpvWidth = math.min(560.0, width * .38); + final fpvHeight = math.min(474.0, height * .52); + const unitTrayHeight = 164.0; + final hangarOpen = + tactical.panel == TacticalPanel.unitHangar || + tactical.panel == TacticalPanel.unitSetup; + // Setup adds an editable local-name form and the capability-gated + // configuration explanation, so it grows without turning the horizontal + // category hangar itself into a tall modal. + final hangarHeight = tactical.panel == TacticalPanel.unitSetup + ? 350.0 + : 252.0; + final mapControlBottom = hangarOpen + ? hangarHeight + 32 + : unitTrayHeight + 32; + + return Stack( + children: [ + Positioned( + left: 20, + top: 16, + right: 20, + child: _TopNavigation( + sourceLabel: sourceLabel, + sourceStatus: sourceStatus, + onOpenEventStream: onOpenEventStream, + ), + ), + const Positioned( + left: 20, + top: 90, + right: 20, + child: _DemoDisclosureBanner(), + ), + if (selectedUnit != null) + Positioned( + right: 24, + top: 136, + width: fpvWidth, + height: fpvHeight, + child: _FpvPanel(selectedUnit: selectedUnit!), + ), + if (selectedUnitIds.length == 1) + Positioned( + right: 24, + top: selectedUnit == null ? 136 : 136 + fpvHeight + 12, + width: fpvWidth, + child: _TacticalInspector( + selectedUnitId: selectedUnitIds.single, + selectedDemo: selectedUnit, + workspaceUnits: workspaceUnits, + ), + ), + if (selectedUnitIds.isEmpty && tactical.selectedDrawingId != null) + Positioned( + right: 24, + top: 136, + width: fpvWidth, + child: _DrawingInspector( + drawing: tactical.drawings.firstWhere( + (drawing) => drawing.id == tactical.selectedDrawingId, + ), + onDelete: () => + onAction(_TacticalAction.removeSelectedSceneObject), + ), + ), + if (selectedUnitIds.isEmpty && tactical.selectedMarkerId != null) + Positioned( + right: 24, + top: 136, + width: fpvWidth, + child: _MarkerInspector( + marker: tactical.markers.firstWhere( + (marker) => marker.id == tactical.selectedMarkerId, + ), + onDelete: () => + onAction(_TacticalAction.removeSelectedSceneObject), + ), + ), + if (tactical.panel == TacticalPanel.fleetRoster) + Positioned( + left: 24, + top: 136, + width: math.min(380, width * .3), + bottom: mapControlBottom, + child: _FleetWorkspaceRoster( + workspaceUnits: workspaceUnits, + tactical: tactical, + onSelected: onUnitSelected, + onClose: () => onAction(_TacticalAction.closeFleet), + ), + ), + Positioned( + left: 24, + bottom: mapControlBottom, + child: _MapEdgeCommandControls( + tactical: tactical, + layers: layers, + onLayerChanged: onLayerChanged, + onAction: onAction, + unitsTriggerFocus: unitsTriggerFocus, + ), + ), + if (hangarOpen) + Positioned( + left: 24, + right: 24, + bottom: 24, + height: hangarHeight, + child: _UnitHangarDock(onClose: onCloseHangar), + ) + else + Positioned( + right: 24, + bottom: 24, + width: math.min(680, width * .48), + height: unitTrayHeight, + child: _UnitCommandTray( + selectedUnit: selectedUnit, + selectedUnitIds: selectedUnitIds, + onUnitSelected: onUnitSelected, + onAddUnit: () => onAction(_TacticalAction.addUnit), + onOpenSetup: () => onAction(_TacticalAction.openUnitSetup), + workspaceUnits: workspaceUnits, + ), + ), + ], + ); + } +} + +class _CompactCommandDeck extends StatelessWidget { + const _CompactCommandDeck({ + required this.layers, + required this.onLayerChanged, + required this.selectedUnit, + required this.selectedUnitIds, + required this.onUnitSelected, + required this.tactical, + required this.workspaceUnits, + required this.onAction, + required this.sourceLabel, + required this.sourceStatus, + required this.onOpenEventStream, + required this.unitsTriggerFocus, + required this.onCloseHangar, + }); + + final Set layers; + final void Function(String layer, bool enabled) onLayerChanged; + final _DroneData? selectedUnit; + final Set selectedUnitIds; + final ValueChanged onUnitSelected; + final TacticalSceneRenderState tactical; + final List workspaceUnits; + final ValueChanged<_TacticalAction> onAction; + final String sourceLabel; + final String sourceStatus; + final VoidCallback onOpenEventStream; + final FocusNode unitsTriggerFocus; + final VoidCallback onCloseHangar; + + @override + Widget build(BuildContext context) => SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _TopNavigation( + compact: true, + sourceLabel: sourceLabel, + sourceStatus: sourceStatus, + onOpenEventStream: onOpenEventStream, + ), + const SizedBox(height: 16), + const _DemoDisclosureBanner(), + const SizedBox(height: 16), + if (selectedUnit != null) + SizedBox(height: 350, child: _FpvPanel(selectedUnit: selectedUnit!)), + const SizedBox(height: 16), + _CompactTacticalActions( + tactical: tactical, + layers: layers, + onLayerChanged: onLayerChanged, + onAction: onAction, + unitsTriggerFocus: unitsTriggerFocus, + ), + const SizedBox(height: 16), + _UnitCommandTray( + selectedUnit: selectedUnit, + selectedUnitIds: selectedUnitIds, + onUnitSelected: onUnitSelected, + onAddUnit: () => onAction(_TacticalAction.addUnit), + onOpenSetup: () => onAction(_TacticalAction.openUnitSetup), + workspaceUnits: workspaceUnits, + ), + if (tactical.panel == TacticalPanel.unitHangar || + tactical.panel == TacticalPanel.unitSetup) ...[ + const SizedBox(height: 16), + SizedBox( + height: 338, + child: _UnitHangarDock(compact: true, onClose: onCloseHangar), + ), + ], + ], + ), + ); +} + +/// RTS action taxonomy: Units changes only the local workspace; Cursor +/// selects or creates session-local scene data; View changes rendering. +/// None of these actions sends a vehicle command or consumes live telemetry. +class _MapEdgeCommandControls extends StatelessWidget { + const _MapEdgeCommandControls({ + required this.tactical, + required this.layers, + required this.onLayerChanged, + required this.onAction, + required this.unitsTriggerFocus, + }); + + final TacticalSceneRenderState tactical; + final Set layers; + final void Function(String layer, bool enabled) onLayerChanged; + final ValueChanged<_TacticalAction> onAction; + final FocusNode unitsTriggerFocus; + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TacticalMenuAnchor( + triggerKey: const Key('units-menu'), + focusNode: unitsTriggerFocus, + label: 'Units', + icon: Icons.groups_2_outlined, + items: [ + _MenuAction( + label: 'Add local unit', + detail: 'Creates local workspace context only', + icon: Icons.add_circle_outline, + onSelected: () => onAction(_TacticalAction.addUnit), + ), + _MenuAction( + label: 'Fleet workspace', + detail: 'Local unit roster is in the selection console', + icon: Icons.view_list_outlined, + onSelected: () => onAction(_TacticalAction.openFleet), + ), + const _MenuAction( + label: 'Live fleet', + detail: 'Requires verified source-to-unit binding', + icon: Icons.radar_outlined, + enabled: false, + ), + ], + ), + const SizedBox(height: 8), + _TacticalMenuAnchor( + triggerKey: const Key('cursor-menu'), + label: 'Mode: ${_modeLabel(tactical.mode)}', + icon: switch (tactical.mode) { + TacticalPencilMode() => Icons.draw_outlined, + TacticalMarkerMode() => Icons.location_on_outlined, + _ => Icons.ads_click_outlined, + }, + items: [ + _MenuAction( + label: 'Selection cursor', + detail: 'Default · click or draw a rectangle to select units', + icon: Icons.ads_click_outlined, + onSelected: () => onAction(_TacticalAction.selectTool), + ), + _MenuAction( + label: 'Pencil cursor', + detail: + 'Draw session-local marks with color and thickness controls', + icon: Icons.draw_outlined, + onSelected: () => onAction(_TacticalAction.pencilTool), + ), + _MenuAction( + label: 'Marker cursor', + detail: 'Click a local point, then give the marker a name', + icon: Icons.location_on_outlined, + onSelected: () => onAction(_TacticalAction.markerTool), + ), + ], + ), + const SizedBox(height: 8), + _TacticalMenuAnchor( + triggerKey: const Key('view-menu'), + label: 'View', + icon: Icons.layers_outlined, + items: [ + for (final layer in ['Drones']) + _MenuAction( + label: '${layers.contains(layer) ? 'Hide' : 'Show'} $layer', + detail: 'Changes the local tactical scene', + icon: layers.contains(layer) + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + onSelected: () => onLayerChanged(layer, !layers.contains(layer)), + ), + _MenuAction( + label: 'Reset local view', + detail: 'Returns the local scene to its default view', + icon: Icons.center_focus_strong_outlined, + onSelected: () => onAction(_TacticalAction.resetView), + ), + _MenuAction( + label: 'Cancel selection', + detail: 'Clears the current local selection', + icon: Icons.close_outlined, + onSelected: () => onAction(_TacticalAction.clearSelection), + ), + _MenuAction( + label: 'Clear local scene', + detail: tactical.drawings.isEmpty && tactical.markers.isEmpty + ? 'No local drawings or markers' + : 'Removes session-local pencil drawings and markers', + icon: Icons.layers_clear_outlined, + enabled: + tactical.drawings.isNotEmpty || tactical.markers.isNotEmpty, + onSelected: () => onAction(_TacticalAction.clearSceneObjects), + ), + ], + ), + ], + ); +} + +class _TacticalMenuAnchor extends StatelessWidget { + const _TacticalMenuAnchor({ + required this.triggerKey, + required this.label, + required this.icon, + required this.items, + this.focusNode, + }); + + final Key triggerKey; + final String label; + final IconData icon; + final List<_MenuAction> items; + final FocusNode? focusNode; + + @override + Widget build(BuildContext context) => MenuAnchor( + alignmentOffset: const Offset(0, -4), + menuChildren: [ + for (final item in items) + MenuItemButton( + onPressed: item.enabled ? item.onSelected : null, + leadingIcon: Icon(item.icon, size: 18), + child: SizedBox( + width: 245, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(item.label), + const SizedBox(height: 3), + Text( + item.detail, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: textSecondary), + ), + ], + ), + ), + ), + ], + builder: (context, controller, child) => _MapEdgeTrigger( + key: triggerKey, + label: label, + icon: icon, + focusNode: focusNode, + onPressed: () => + controller.isOpen ? controller.close() : controller.open(), + ), + ); +} + +class _MenuAction { + const _MenuAction({ + required this.label, + required this.detail, + required this.icon, + this.enabled = true, + this.onSelected, + }); + + final String label; + final String detail; + final IconData icon; + final bool enabled; + final VoidCallback? onSelected; +} + +class _MapEdgeTrigger extends StatelessWidget { + const _MapEdgeTrigger({ + super.key, + required this.label, + required this.icon, + required this.onPressed, + this.focusNode, + }); + + final String label; + final IconData icon; + final VoidCallback onPressed; + final FocusNode? focusNode; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: label, + child: Tooltip( + message: label, + child: SizedBox( + width: 48, + height: 48, + child: _TacticalGlass( + borderRadius: BorderRadius.circular(12), + child: TextButton( + focusNode: focusNode, + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: accentMint, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(11), + ), + ), + child: Icon(icon, size: 21), + ), + ), + ), + ), + ); +} + +class _CompactTacticalActions extends StatelessWidget { + const _CompactTacticalActions({ + required this.tactical, + required this.layers, + required this.onLayerChanged, + required this.onAction, + required this.unitsTriggerFocus, + }); + + final TacticalSceneRenderState tactical; + final Set layers; + final void Function(String layer, bool enabled) onLayerChanged; + final ValueChanged<_TacticalAction> onAction; + final FocusNode unitsTriggerFocus; + + @override + Widget build(BuildContext context) => Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _CompactActionButton( + label: 'Units', + icon: Icons.groups_2_outlined, + focusNode: unitsTriggerFocus, + onPressed: () => _showActionSheet( + context, + title: 'Units', + actions: [ + _MenuAction( + label: 'Add local unit', + detail: 'Creates local workspace context only', + icon: Icons.add_circle_outline, + onSelected: () => onAction(_TacticalAction.addUnit), + ), + _MenuAction( + label: 'Fleet workspace', + detail: 'Open the local unit roster', + icon: Icons.view_list_outlined, + onSelected: () => onAction(_TacticalAction.openFleet), + ), + const _MenuAction( + label: 'Live fleet', + detail: 'Requires verified source-to-unit binding', + icon: Icons.radar_outlined, + enabled: false, + ), + ], + ), + ), + _CompactActionButton( + label: 'Mode: ${_modeLabel(tactical.mode)}', + icon: switch (tactical.mode) { + TacticalPencilMode() => Icons.draw_outlined, + TacticalMarkerMode() => Icons.location_on_outlined, + _ => Icons.ads_click_outlined, + }, + onPressed: () => _showActionSheet( + context, + title: 'Cursor', + actions: [ + _MenuAction( + label: 'Selection cursor', + detail: 'Default · click or draw a rectangle to select units', + icon: Icons.ads_click_outlined, + onSelected: () => onAction(_TacticalAction.selectTool), + ), + _MenuAction( + label: 'Pencil cursor', + detail: + 'Draw session-local marks with color and thickness controls', + icon: Icons.draw_outlined, + onSelected: () => onAction(_TacticalAction.pencilTool), + ), + _MenuAction( + label: 'Marker cursor', + detail: 'Click a local point, then give the marker a name', + icon: Icons.location_on_outlined, + onSelected: () => onAction(_TacticalAction.markerTool), + ), + ], + ), + ), + _CompactActionButton( + label: 'View', + icon: Icons.layers_outlined, + onPressed: () => + _showViewSheet(context, tactical, layers, onLayerChanged, onAction), + ), + ], + ); +} + +class _CompactActionButton extends StatelessWidget { + const _CompactActionButton({ + required this.label, + required this.icon, + required this.onPressed, + this.focusNode, + }); + + final String label; + final IconData icon; + final VoidCallback onPressed; + final FocusNode? focusNode; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: label, + child: Tooltip( + message: label, + child: SizedBox( + width: 48, + height: 48, + child: _TacticalGlass( + borderRadius: BorderRadius.circular(12), + child: TextButton( + focusNode: focusNode, + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: accentMint, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(11), + ), + ), + child: Icon(icon, size: 21), + ), + ), + ), + ), + ); +} + +Future _showActionSheet( + BuildContext context, { + required String title, + required List<_MenuAction> actions, +}) => showModalBottomSheet( + context: context, + backgroundColor: tacticalBlack, + isScrollControlled: true, + builder: (context) => SafeArea( + child: SizedBox( + height: MediaQuery.sizeOf(context).height * .82, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + const SizedBox(height: 12), + Expanded( + child: ListView( + key: const Key('tactical-action-sheet-scroll'), + children: [ + for (final action in actions) + ListTile( + enabled: action.enabled, + leading: Icon(action.icon), + title: Text(action.label), + subtitle: Text(action.detail), + onTap: action.enabled + ? () { + Navigator.pop(context); + action.onSelected?.call(); + } + : null, + ), + ], + ), + ), + ], + ), + ), + ), + ), +); + +Future _showViewSheet( + BuildContext context, + TacticalSceneRenderState tactical, + Set layers, + void Function(String layer, bool enabled) onLayerChanged, + ValueChanged<_TacticalAction> onAction, +) => _showActionSheet( + context, + title: 'View', + actions: [ + for (final label in ['Drones']) + _MenuAction( + label: '${layers.contains(label) ? 'Hide' : 'Show'} $label', + detail: 'Changes the local tactical scene', + icon: layers.contains(label) + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + onSelected: () => onLayerChanged(label, !layers.contains(label)), + ), + _MenuAction( + label: 'Reset local view', + detail: 'Returns the local scene to its default view', + icon: Icons.center_focus_strong_outlined, + onSelected: () => onAction(_TacticalAction.resetView), + ), + _MenuAction( + label: 'Cancel selection', + detail: 'Clears the current local selection', + icon: Icons.close_outlined, + onSelected: () => onAction(_TacticalAction.clearSelection), + ), + _MenuAction( + label: 'Clear local scene', + detail: tactical.drawings.isEmpty && tactical.markers.isEmpty + ? 'No local drawings or markers' + : 'Removes session-local pencil drawings and markers', + icon: Icons.layers_clear_outlined, + enabled: tactical.drawings.isNotEmpty || tactical.markers.isNotEmpty, + onSelected: () => onAction(_TacticalAction.clearSceneObjects), + ), + ], +); + +class _TacticalEditOverlay extends StatelessWidget { + const _TacticalEditOverlay({ + required this.tactical, + required this.onAction, + required this.onStrokeComplete, + required this.cursor, + required this.onNudgeCursor, + required this.onCommitCursor, + required this.onFinishKeyboardPencil, + required this.onConfigurePencil, + required this.onConfigureMarker, + required this.onCommitMarkerName, + }); + + final TacticalUiState tactical; + final ValueChanged<_TacticalAction> onAction; + final ValueChanged> onStrokeComplete; + final LocalScenePoint? cursor; + final void Function(double dx, double dy) onNudgeCursor; + final VoidCallback onCommitCursor; + final VoidCallback onFinishKeyboardPencil; + final void Function({ + double? hue, + double? saturation, + double? brightness, + double? thickness, + }) + onConfigurePencil; + final void Function({double? hue, double? saturation, double? brightness}) + onConfigureMarker; + final ValueChanged onCommitMarkerName; + + @override + Widget build(BuildContext context) { + final mode = tactical.mode; + if (mode is TacticalPencilMode) { + return _PencilToolOverlay( + mode: mode, + cursor: cursor, + onStrokeComplete: onStrokeComplete, + onCancel: () => onAction(_TacticalAction.cancel), + onNudgeCursor: onNudgeCursor, + onCommitCursor: onCommitCursor, + onFinishKeyboardPencil: onFinishKeyboardPencil, + onConfigure: onConfigurePencil, + ); + } + if (mode is TacticalMarkerMode) { + return _MarkerToolOverlay( + mode: mode, + cursor: cursor, + onCancel: () => onAction(_TacticalAction.cancel), + onNudgeCursor: onNudgeCursor, + onCommitCursor: onCommitCursor, + onConfigure: onConfigureMarker, + onSaveName: onCommitMarkerName, + onSelectTool: () => onAction(_TacticalAction.selectTool), + ); + } + return const SizedBox.shrink(); + } +} + +class _PencilToolOverlay extends StatelessWidget { + const _PencilToolOverlay({ + required this.mode, + required this.cursor, + required this.onStrokeComplete, + required this.onCancel, + required this.onNudgeCursor, + required this.onCommitCursor, + required this.onFinishKeyboardPencil, + required this.onConfigure, + }); + + final TacticalPencilMode mode; + final LocalScenePoint? cursor; + final ValueChanged> onStrokeComplete; + final VoidCallback onCancel; + final void Function(double dx, double dy) onNudgeCursor; + final VoidCallback onCommitCursor; + final VoidCallback onFinishKeyboardPencil; + final void Function({ + double? hue, + double? saturation, + double? brightness, + double? thickness, + }) + onConfigure; + + @override + Widget build(BuildContext context) => Stack( + children: [ + Positioned.fill( + child: _TacticalPointerInput( + mode: mode, + onPoint: (_) {}, + onStrokeComplete: onStrokeComplete, + onCancel: onCancel, + ), + ), + Positioned( + left: 24, + right: 24, + top: 116, + child: FocusTraversalGroup( + child: FocusScope( + autofocus: true, + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + SingleActivator(LogicalKeyboardKey.arrowLeft): + _NudgeSceneCursorIntent(-.04, 0), + SingleActivator(LogicalKeyboardKey.arrowRight): + _NudgeSceneCursorIntent(.04, 0), + SingleActivator(LogicalKeyboardKey.arrowUp): + _NudgeSceneCursorIntent(0, -.04), + SingleActivator(LogicalKeyboardKey.arrowDown): + _NudgeSceneCursorIntent(0, .04), + SingleActivator(LogicalKeyboardKey.enter): + _CommitSceneCursorIntent(), + }, + child: Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (_) { + onCancel(); + return null; + }, + ), + _NudgeSceneCursorIntent: + CallbackAction<_NudgeSceneCursorIntent>( + onInvoke: (intent) { + onNudgeCursor(intent.dx, intent.dy); + return null; + }, + ), + _CommitSceneCursorIntent: + CallbackAction<_CommitSceneCursorIntent>( + onInvoke: (_) { + onCommitCursor(); + return null; + }, + ), + }, + child: Semantics( + container: true, + label: 'Pencil cursor. Session-only local drawing tool.', + child: _PencilToolbar( + mode: mode, + cursor: cursor ?? const LocalScenePoint(x: .5, y: .5), + onConfigure: onConfigure, + onAddPoint: onCommitCursor, + onFinish: onFinishKeyboardPencil, + onCancel: onCancel, + ), + ), + ), + ), + ), + ), + ), + ], + ); +} + +class _PencilToolbar extends StatelessWidget { + const _PencilToolbar({ + required this.mode, + required this.cursor, + required this.onConfigure, + required this.onAddPoint, + required this.onFinish, + required this.onCancel, + }); + + final TacticalPencilMode mode; + final LocalScenePoint cursor; + final void Function({ + double? hue, + double? saturation, + double? brightness, + double? thickness, + }) + onConfigure; + final VoidCallback onAddPoint; + final VoidCallback onFinish; + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) => _TacticalGlass( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Wrap( + spacing: 14, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const _HudLabel('PENCIL CURSOR', color: accentOrange), + _ColorSwatches( + toolLabel: 'Pencil', + keyPrefix: 'pencil', + hue: mode.hue, + onSelected: (hue) => onConfigure(hue: hue), + ), + SizedBox( + width: 160, + child: Semantics( + label: 'Pencil color hue', + child: Slider( + key: const Key('pencil-hue'), + value: mode.hue, + min: 0, + max: 360, + activeColor: _pencilColor( + mode.hue, + mode.saturation, + mode.brightness, + ), + onChanged: (hue) => onConfigure(hue: hue), + ), + ), + ), + SizedBox( + width: 130, + child: Semantics( + label: 'Pencil thickness', + child: Slider( + key: const Key('pencil-thickness'), + value: mode.thickness, + min: 1, + max: 12, + divisions: 11, + activeColor: _pencilColor( + mode.hue, + mode.saturation, + mode.brightness, + ), + onChanged: (thickness) => onConfigure(thickness: thickness), + ), + ), + ), + _HudLabel('${mode.thickness.toStringAsFixed(0)} PX'), + _StyleSlider( + key: const Key('pencil-saturation'), + label: 'Pencil saturation', + value: mode.saturation, + color: _pencilColor(mode.hue, 1, mode.brightness), + onChanged: (value) => onConfigure(saturation: value), + ), + _StyleSlider( + key: const Key('pencil-brightness'), + label: 'Pencil brightness', + value: mode.brightness, + color: _pencilColor(mode.hue, mode.saturation, 1), + onChanged: (value) => onConfigure(brightness: value), + ), + _KeyboardMapCursor( + cursor: cursor, + isDrawing: true, + canFinishDrawing: mode.keyboardPoints.length >= 2, + pointCount: mode.keyboardPoints.length, + onCommit: onAddPoint, + onFinish: onFinish, + ), + _UnitActionButton( + label: 'SELECTION CURSOR', + icon: Icons.ads_click_outlined, + foreground: textPrimary, + background: tacticalTransparent, + onPressed: onCancel, + ), + ], + ), + ); +} + +class _ColorSwatches extends StatelessWidget { + const _ColorSwatches({ + required this.toolLabel, + required this.keyPrefix, + required this.hue, + required this.onSelected, + this.colorForHue = _pencilColor, + }); + + final String toolLabel; + final String keyPrefix; + final double hue; + final ValueChanged onSelected; + final Color Function(double hue) colorForHue; + + @override + Widget build(BuildContext context) => Wrap( + spacing: 6, + children: [ + for (final swatchHue in [0.0, 26.0, 58.0, 164.0, 208.0, 282.0, 332.0]) + Semantics( + button: true, + selected: (hue - swatchHue).abs() < 2, + label: '$toolLabel color ${swatchHue.round()} degrees', + child: InkWell( + key: Key('$keyPrefix-swatch-${swatchHue.round()}'), + onTap: () => onSelected(swatchHue), + borderRadius: BorderRadius.circular(12), + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: colorForHue(swatchHue), + shape: BoxShape.circle, + border: Border.all( + color: (hue - swatchHue).abs() < 2 + ? textPrimary + : panelBorder, + width: 2, + ), + ), + ), + ), + ), + ], + ); +} + +Color _pencilColor(double hue, [double saturation = .78, double value = .94]) => + HSVColor.fromAHSV(1, hue, saturation, value).toColor(); + +class _MarkerToolOverlay extends StatelessWidget { + const _MarkerToolOverlay({ + required this.mode, + required this.cursor, + required this.onCancel, + required this.onNudgeCursor, + required this.onCommitCursor, + required this.onConfigure, + required this.onSaveName, + required this.onSelectTool, + }); + + final TacticalMarkerMode mode; + final LocalScenePoint? cursor; + final VoidCallback onCancel; + final void Function(double dx, double dy) onNudgeCursor; + final VoidCallback onCommitCursor; + final void Function({double? hue, double? saturation, double? brightness}) + onConfigure; + final ValueChanged onSaveName; + final VoidCallback onSelectTool; + + @override + Widget build(BuildContext context) => Stack( + children: [ + Positioned.fill( + child: _TacticalPointerInput( + mode: mode, + onPoint: (point) => ProviderScope.containerOf( + context, + ).read(tacticalControllerProvider.notifier).chooseMarkerPoint(point), + onStrokeComplete: (_) {}, + onCancel: onCancel, + ), + ), + Positioned( + left: 24, + right: 24, + top: 116, + child: FocusTraversalGroup( + child: FocusScope( + autofocus: true, + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + SingleActivator(LogicalKeyboardKey.arrowLeft): + _NudgeSceneCursorIntent(-.04, 0), + SingleActivator(LogicalKeyboardKey.arrowRight): + _NudgeSceneCursorIntent(.04, 0), + SingleActivator(LogicalKeyboardKey.arrowUp): + _NudgeSceneCursorIntent(0, -.04), + SingleActivator(LogicalKeyboardKey.arrowDown): + _NudgeSceneCursorIntent(0, .04), + SingleActivator(LogicalKeyboardKey.enter): + _CommitSceneCursorIntent(), + }, + child: Actions( + actions: { + DismissIntent: CallbackAction( + onInvoke: (_) { + onCancel(); + return null; + }, + ), + _NudgeSceneCursorIntent: + CallbackAction<_NudgeSceneCursorIntent>( + onInvoke: (intent) { + onNudgeCursor(intent.dx, intent.dy); + return null; + }, + ), + _CommitSceneCursorIntent: + CallbackAction<_CommitSceneCursorIntent>( + onInvoke: (_) { + onCommitCursor(); + return null; + }, + ), + }, + child: Semantics( + container: true, + label: 'Marker cursor. Session-only local marker tool.', + child: _MarkerToolbar( + mode: mode, + cursor: cursor ?? const LocalScenePoint(x: .5, y: .5), + onConfigure: onConfigure, + onPlaceAtCursor: onCommitCursor, + onSelectTool: onSelectTool, + ), + ), + ), + ), + ), + ), + ), + if (mode.pendingPoint != null) + Positioned( + left: 24, + top: 220, + width: math.min(360, MediaQuery.sizeOf(context).width - 48), + child: _MarkerNamePrompt(onSave: onSaveName, onCancel: onCancel), + ), + ], + ); +} + +class _MarkerToolbar extends StatelessWidget { + const _MarkerToolbar({ + required this.mode, + required this.cursor, + required this.onConfigure, + required this.onPlaceAtCursor, + required this.onSelectTool, + }); + + final TacticalMarkerMode mode; + final LocalScenePoint cursor; + final void Function({double? hue, double? saturation, double? brightness}) + onConfigure; + final VoidCallback onPlaceAtCursor; + final VoidCallback onSelectTool; + + @override + Widget build(BuildContext context) => _TacticalGlass( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Wrap( + spacing: 14, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const _HudLabel('MARKER CURSOR', color: accentOrange), + _ColorSwatches( + toolLabel: 'Marker', + keyPrefix: 'marker', + hue: mode.hue, + colorForHue: _markerColor, + onSelected: (hue) => onConfigure(hue: hue), + ), + SizedBox( + width: 160, + child: Semantics( + label: 'Marker color hue', + child: Slider( + key: const Key('marker-hue'), + value: mode.hue, + min: 0, + max: 360, + activeColor: _markerColor( + mode.hue, + mode.saturation, + mode.brightness, + ), + onChanged: (hue) => onConfigure(hue: hue), + ), + ), + ), + _StyleSlider( + key: const Key('marker-saturation'), + label: 'Marker saturation', + value: mode.saturation, + color: _markerColor(mode.hue, 1, mode.brightness), + onChanged: (value) => onConfigure(saturation: value), + ), + _StyleSlider( + key: const Key('marker-brightness'), + label: 'Marker brightness', + value: mode.brightness, + color: _markerColor(mode.hue, mode.saturation, 1), + onChanged: (value) => onConfigure(brightness: value), + ), + _KeyboardMapCursor( + cursor: cursor, + isDrawing: false, + canFinishDrawing: false, + pointCount: 0, + onCommit: onPlaceAtCursor, + onFinish: () {}, + ), + _UnitActionButton( + label: 'SELECTION CURSOR', + icon: Icons.ads_click_outlined, + foreground: textPrimary, + background: tacticalTransparent, + onPressed: onSelectTool, + ), + ], + ), + ); +} + +class _MarkerNamePrompt extends StatefulWidget { + const _MarkerNamePrompt({required this.onSave, required this.onCancel}); + + final ValueChanged onSave; + final VoidCallback onCancel; + + @override + State<_MarkerNamePrompt> createState() => _MarkerNamePromptState(); +} + +class _MarkerNamePromptState extends State<_MarkerNamePrompt> { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _focusNode.requestFocus(), + ); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _submit() { + final name = _controller.text; + if (name.trim().isEmpty) { + setState(() => _error = 'Enter a marker name.'); + return; + } + if (name.runes.length > 48) { + setState(() => _error = 'Marker names are limited to 48 characters.'); + return; + } + widget.onSave(name); + } + + @override + Widget build(BuildContext context) => Semantics( + container: true, + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: 'Name local marker', + child: _TacticalGlass( + padding: const EdgeInsets.all(14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _HudLabel('NAME LOCAL MARKER', color: accentOrange), + const SizedBox(height: 6), + const Text('Session only · not sent to a vehicle'), + const SizedBox(height: 10), + TextField( + key: const Key('marker-name-field'), + focusNode: _focusNode, + controller: _controller, + maxLength: 48, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + decoration: InputDecoration( + labelText: 'Marker name', + errorText: _error, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + _UnitActionButton( + label: 'SAVE MARKER', + icon: Icons.check, + foreground: tacticalBlack, + background: accentMint, + onPressed: _submit, + ), + _UnitActionButton( + label: 'CANCEL', + icon: Icons.close, + foreground: textPrimary, + background: tacticalTransparent, + onPressed: widget.onCancel, + ), + ], + ), + ], + ), + ), + ); +} + +Color _markerColor(double hue, [double saturation = .68, double value = .98]) => + HSVColor.fromAHSV(1, hue, saturation, value).toColor(); + +class _StyleSlider extends StatelessWidget { + const _StyleSlider({ + super.key, + required this.label, + required this.value, + required this.color, + required this.onChanged, + }); + + final String label; + final double value; + final Color color; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => SizedBox( + width: 130, + child: Semantics( + label: label, + child: Slider( + value: value, + min: 0, + max: 1, + activeColor: color, + onChanged: onChanged, + ), + ), + ); +} + +class _KeyboardMapCursor extends StatelessWidget { + const _KeyboardMapCursor({ + required this.cursor, + required this.isDrawing, + required this.canFinishDrawing, + required this.pointCount, + required this.onCommit, + required this.onFinish, + }); + + final LocalScenePoint cursor; + final bool isDrawing; + final bool canFinishDrawing; + final int pointCount; + final VoidCallback onCommit; + final VoidCallback onFinish; + + @override + Widget build(BuildContext context) { + final location = _qualitativeSceneLocation(cursor); + return Semantics( + liveRegion: true, + label: 'Map cursor: $location. Local scene only, not a vehicle location.', + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _HudLabel('MAP CURSOR · $location', color: textSecondary), + if (isDrawing) _HudLabel('LOCAL POINTS: $pointCount'), + OutlinedButton.icon( + onPressed: onCommit, + icon: const Icon(Icons.add_location_alt_outlined, size: 16), + label: Text(isDrawing ? 'ADD LOCAL POINT' : 'PLACE AT CURSOR'), + ), + if (isDrawing) + FilledButton.icon( + onPressed: canFinishDrawing ? onFinish : null, + icon: const Icon(Icons.check, size: 16), + label: const Text('FINISH DRAWING'), + ), + ], + ), + ); + } +} + +String _qualitativeSceneLocation(LocalScenePoint point) { + final horizontal = point.x < .34 + ? 'left' + : point.x > .66 + ? 'right' + : 'center'; + final vertical = point.y < .34 + ? 'upper' + : point.y > .66 + ? 'lower' + : 'middle'; + return '$vertical $horizontal'; +} + +/// The production-style unit hangar: a horizontal, local-only RTS dock. +/// Selecting a category prepares a draft; it cannot create or configure a +/// vehicle. Confirmation creates only a local workspace unit. +class _UnitHangarDock extends ConsumerWidget { + const _UnitHangarDock({this.compact = false, required this.onClose}); + + final bool compact; + final VoidCallback onClose; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tactical = ref.watch(tacticalControllerProvider); + final workspace = ref.watch(vehicleWorkspaceProvider); + final controller = ref.read(tacticalControllerProvider.notifier); + final panel = tactical.panel; + final draft = tactical.unitDraft; + FleetUnit? selectedLocal; + if (tactical.selectedUnitIds.length == 1) { + for (final unit in workspace.units) { + if (unit.localUnitId == tactical.selectedUnitIds.single) { + selectedLocal = unit; + break; + } + } + } + + return RepaintBoundary( + child: _TacticalGlass( + padding: EdgeInsets.all(compact ? 14 : 16), + borderRadius: BorderRadius.circular(14), + child: switch (panel) { + TacticalPanel.unitHangar => _HangarSelection( + draft: draft, + onSelect: (definition) => controller.chooseUnitDefinition( + definition, + displayName: _nextLocalUnitName( + definition, + workspace.units.length, + ), + ), + onPrepare: controller.beginUnitSetup, + onClose: onClose, + ), + TacticalPanel.unitSetup when draft != null => _DraftUnitSetup( + draft: draft, + onNameChanged: controller.updateUnitDraftName, + onBack: controller.returnToUnitHangar, + onConfirm: () { + final localUnitId = 'local-unit-${workspace.units.length + 1}'; + final created = ref + .read(vehicleWorkspaceProvider.notifier) + .addUnitAutomatically( + definition: draft.definition, + displayName: draft.displayName, + ); + if (created) { + controller.completeUnitPlacement(localUnitId); + } + }, + ), + TacticalPanel.unitSetup when selectedLocal != null => + _ExistingUnitSetup( + unit: selectedLocal, + onClose: onClose, + onSaveName: (name) => ref + .read(vehicleWorkspaceProvider.notifier) + .renameUnit(selectedLocal!.localUnitId, name), + ), + _ => const SizedBox.shrink(), + }, + ), + ); + } +} + +String _nextLocalUnitName(VehicleDefinition definition, int unitCount) => + '${definition.name.toUpperCase()}-${(unitCount + 1).toString().padLeft(2, '0')}'; + +class _HangarSelection extends StatefulWidget { + const _HangarSelection({ + required this.draft, + required this.onSelect, + required this.onPrepare, + required this.onClose, + }); + + final UnitCreationDraft? draft; + final ValueChanged onSelect; + final VoidCallback onPrepare; + final VoidCallback onClose; + + @override + State<_HangarSelection> createState() => _HangarSelectionState(); +} + +class _HangarSelectionState extends State<_HangarSelection> { + late final List _cardFocusNodes; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _cardFocusNodes = List.generate( + supportedVehicleDefinitions.length, + (index) => FocusNode(debugLabel: 'hangar-card-$index'), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _cardFocusNodes.first.requestFocus(); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + for (final node in _cardFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + void _choose(int index) => + widget.onSelect(supportedVehicleDefinitions[index]); + + void _moveFocus(int target) { + final index = target.clamp(0, supportedVehicleDefinitions.length - 1); + _choose(index); + _cardFocusNodes[index].requestFocus(); + final context = _cardFocusNodes[index].context; + if (context != null) { + Scrollable.ensureVisible( + context, + alignment: .5, + duration: const Duration(milliseconds: 120), + ); + } + } + + int get _selectedIndex { + final id = widget.draft?.definition.id; + return supportedVehicleDefinitions.indexWhere( + (definition) => definition.id == id, + ); + } + + @override + Widget build(BuildContext context) => FocusTraversalGroup( + child: Semantics( + container: true, + label: 'Unit hangar. Local scene vehicle categories.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.inventory_2_outlined, + color: accentMint, + size: 18, + ), + const SizedBox(width: 8), + const _HudLabel('UNIT HANGAR', color: textPrimary), + const SizedBox(width: 8), + const _Token(label: 'LOCAL SCENE', tone: textSecondary), + const Spacer(), + IconButton( + tooltip: 'Close unit hangar', + onPressed: widget.onClose, + icon: const Icon(Icons.close_outlined), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.arrowLeft): + _HangarMoveIntent(-1), + SingleActivator(LogicalKeyboardKey.arrowRight): + _HangarMoveIntent(1), + SingleActivator(LogicalKeyboardKey.home): _HangarHomeIntent(), + SingleActivator(LogicalKeyboardKey.end): _HangarEndIntent(), + }, + child: Actions( + actions: { + _HangarMoveIntent: CallbackAction<_HangarMoveIntent>( + onInvoke: (intent) { + _moveFocus( + (_selectedIndex < 0 ? 0 : _selectedIndex) + + intent.delta, + ); + return null; + }, + ), + _HangarHomeIntent: CallbackAction<_HangarHomeIntent>( + onInvoke: (_) { + _moveFocus(0); + return null; + }, + ), + _HangarEndIntent: CallbackAction<_HangarEndIntent>( + onInvoke: (_) { + _moveFocus(supportedVehicleDefinitions.length - 1); + return null; + }, + ), + }, + child: Scrollbar( + controller: _scrollController, + child: ListView.separated( + key: const Key('unit-hangar-strip'), + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(bottom: 2), + itemCount: supportedVehicleDefinitions.length, + separatorBuilder: (context, index) => + const SizedBox(width: 10), + itemBuilder: (context, index) { + final definition = supportedVehicleDefinitions[index]; + return _HangarVehicleCard( + key: Key('hangar-card-${definition.id}'), + focusNode: _cardFocusNodes[index], + autofocus: index == 0, + definition: definition, + selected: widget.draft?.definition.id == definition.id, + onSelected: () => _choose(index), + ); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _HudLabel( + widget.draft == null + ? 'SELECT A LOCAL UNIT TYPE' + : '${widget.draft!.definition.name.toUpperCase()} SELECTED', + ), + ), + _UnitActionButton( + label: 'PREPARE UNIT', + icon: Icons.tune_outlined, + foreground: tacticalBlack, + background: accentMint, + onPressed: widget.draft == null ? null : widget.onPrepare, + ), + ], + ), + ], + ), + ), + ); +} + +/// Image-first card for the low, horizontal RTS hangar. Unlike the former +/// workspace-selection card, it has exactly one job: choose a draft type. +/// Creating a unit remains an explicit setup confirmation. +class _HangarVehicleCard extends StatefulWidget { + const _HangarVehicleCard({ + super.key, + required this.definition, + required this.selected, + required this.onSelected, + required this.focusNode, + required this.autofocus, + }); + + final VehicleDefinition definition; + final bool selected; + final VoidCallback onSelected; + final FocusNode focusNode; + final bool autofocus; + + @override + State<_HangarVehicleCard> createState() => _HangarVehicleCardState(); +} + +class _HangarVehicleCardState extends State<_HangarVehicleCard> { + bool _focused = false; + + @override + Widget build(BuildContext context) { + final active = widget.selected || _focused; + return Semantics( + button: true, + checked: widget.selected, + inMutuallyExclusiveGroup: true, + label: + '${widget.definition.name}, ${widget.definition.vehicleClass}, ' + 'local unit category', + onTap: widget.onSelected, + child: SizedBox( + width: 186, + child: Material( + color: Colors.transparent, + child: InkWell( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + onFocusChange: (value) => setState(() => _focused = value), + onTap: widget.onSelected, + borderRadius: BorderRadius.circular(9), + child: AnimatedContainer( + duration: const Duration(milliseconds: 110), + curve: Curves.easeOut, + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: active + ? accentMint.withValues(alpha: .12) + : tacticalBlack.withValues(alpha: .48), + border: Border.all( + color: active ? accentMint : panelBorder, + width: active ? 1.5 : 1, + ), + borderRadius: BorderRadius.circular(9), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image.asset( + widget.definition.media.imageAssetPath, + width: double.infinity, + fit: BoxFit.cover, + filterQuality: FilterQuality.high, + excludeFromSemantics: true, + ), + ), + ), + const SizedBox(height: 6), + _HudLabel(widget.definition.vehicleClass), + const SizedBox(height: 2), + Row( + children: [ + Icon( + widget.selected + ? Icons.radio_button_checked + : Icons.radio_button_off, + color: widget.selected ? accentMint : textSecondary, + size: 14, + ), + const SizedBox(width: 5), + Expanded( + child: Text( + widget.definition.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + color: textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _HangarMoveIntent extends Intent { + const _HangarMoveIntent(this.delta); + + final int delta; +} + +class _HangarHomeIntent extends Intent { + const _HangarHomeIntent(); +} + +class _HangarEndIntent extends Intent { + const _HangarEndIntent(); +} + +class _DraftUnitSetup extends StatefulWidget { + const _DraftUnitSetup({ + required this.draft, + required this.onNameChanged, + required this.onBack, + required this.onConfirm, + }); + + final UnitCreationDraft draft; + final ValueChanged onNameChanged; + final VoidCallback onBack; + final VoidCallback onConfirm; + + @override + State<_DraftUnitSetup> createState() => _DraftUnitSetupState(); +} + +class _DraftUnitSetupState extends State<_DraftUnitSetup> { + late final TextEditingController _nameController; + String? _error; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.draft.displayName); + } + + @override + void didUpdateWidget(covariant _DraftUnitSetup oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.draft.definition.id != widget.draft.definition.id) { + _nameController.text = widget.draft.displayName; + } + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + void _confirm() { + if (!VehicleWorkspaceController.isValidLocalDisplayName( + _nameController.text, + )) { + setState( + () => _error = 'Enter a local unit name of up to 32 characters.', + ); + return; + } + setState(() => _error = null); + widget.onConfirm(); + } + + @override + Widget build(BuildContext context) => _UnitSetupLayout( + label: 'UNIT SETUP', + definition: widget.draft.definition, + nameController: _nameController, + editable: true, + error: _error, + onNameChanged: (value) { + if (_error != null && + VehicleWorkspaceController.isValidLocalDisplayName(value)) { + setState(() => _error = null); + } + widget.onNameChanged(value); + }, + primaryLabel: 'ADD TO LOCAL SCENE', + onPrimary: _confirm, + secondaryLabel: 'BACK TO HANGAR', + onSecondary: widget.onBack, + ); +} + +class _ExistingUnitSetup extends StatefulWidget { + const _ExistingUnitSetup({ + required this.unit, + required this.onClose, + required this.onSaveName, + }); + + final FleetUnit unit; + final VoidCallback onClose; + final bool Function(String name) onSaveName; + + @override + State<_ExistingUnitSetup> createState() => _ExistingUnitSetupState(); +} + +class _ExistingUnitSetupState extends State<_ExistingUnitSetup> { + late final TextEditingController _nameController; + String? _error; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.unit.displayName); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + void _save() { + if (!widget.onSaveName(_nameController.text)) { + setState( + () => _error = 'Enter a local unit name of up to 32 characters.', + ); + return; + } + setState(() => _error = null); + } + + @override + Widget build(BuildContext context) => _UnitSetupLayout( + label: 'LOCAL UNIT SETUP', + definition: widget.unit.definition, + nameController: _nameController, + editable: true, + error: _error, + onNameChanged: (_) {}, + primaryLabel: 'SAVE LOCAL SETUP', + onPrimary: _save, + secondaryLabel: 'CLOSE', + onSecondary: widget.onClose, + ); +} + +class _UnitSetupLayout extends StatelessWidget { + const _UnitSetupLayout({ + required this.label, + required this.definition, + required this.nameController, + required this.editable, + required this.onNameChanged, + required this.primaryLabel, + required this.onPrimary, + required this.secondaryLabel, + required this.onSecondary, + this.error, + }); + + final String label; + final VehicleDefinition definition; + final TextEditingController nameController; + final bool editable; + final ValueChanged onNameChanged; + final String primaryLabel; + final VoidCallback onPrimary; + final String secondaryLabel; + final VoidCallback onSecondary; + final String? error; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; + // A tall stacked setup remains scrollable inside the compact dock at + // large text sizes, rather than compressing the identity/form columns. + final stacked = constraints.maxWidth < 760 || textScale >= 1.25; + final identity = _UnitSetupIdentity(definition: definition); + final fields = _UnitSetupFields( + definition: definition, + nameController: nameController, + editable: editable, + error: error, + onNameChanged: onNameChanged, + primaryLabel: primaryLabel, + onPrimary: onPrimary, + secondaryLabel: secondaryLabel, + onSecondary: onSecondary, + ); + return Semantics( + container: true, + label: + '$label. Local workspace only. No vehicle link or command authority.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _HudLabel(label, color: textPrimary), + const SizedBox(height: 10), + Expanded( + child: stacked + ? ListView( + children: [identity, const SizedBox(height: 12), fields], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(width: 260, child: identity), + const SizedBox(width: 18), + Expanded(child: fields), + ], + ), + ), + ], + ), + ); + }, + ); +} + +class _UnitSetupIdentity extends StatelessWidget { + const _UnitSetupIdentity({required this.definition}); + + final VehicleDefinition definition; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 104, + width: double.infinity, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset( + definition.media.imageAssetPath, + fit: BoxFit.cover, + excludeFromSemantics: true, + ), + ), + ), + const SizedBox(height: 8), + _HudLabel(definition.vehicleClass), + Text(definition.name, style: Theme.of(context).textTheme.titleMedium), + const _HudLabel('LOCAL SCENE UNIT'), + ], + ); +} + +class _UnitSetupFields extends StatelessWidget { + const _UnitSetupFields({ + required this.definition, + required this.nameController, + required this.editable, + required this.onNameChanged, + required this.primaryLabel, + required this.onPrimary, + required this.secondaryLabel, + required this.onSecondary, + this.error, + }); + + final VehicleDefinition definition; + final TextEditingController nameController; + final bool editable; + final ValueChanged onNameChanged; + final String primaryLabel; + final VoidCallback onPrimary; + final String secondaryLabel; + final VoidCallback onSecondary; + final String? error; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + key: const Key('local-unit-name-field'), + controller: nameController, + enabled: editable, + maxLength: 32, + onChanged: onNameChanged, + decoration: InputDecoration( + labelText: 'Unit name', + errorText: error, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + _SetupFact(label: 'TYPE', value: definition.name), + const SizedBox(height: 6), + const _SetupFact(label: 'PLACEMENT', value: 'AUTOMATIC LOCAL POSITION'), + const SizedBox(height: 10), + const _ConfigurationUnavailable(), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _UnitActionButton( + label: primaryLabel, + icon: Icons.check_outlined, + foreground: tacticalBlack, + background: accentMint, + onPressed: onPrimary, + ), + _UnitActionButton( + label: secondaryLabel, + icon: Icons.arrow_back_outlined, + foreground: textPrimary, + background: tacticalTransparent, + onPressed: onSecondary, + ), + ], + ), + ], + ); +} + +class _SetupFact extends StatelessWidget { + const _SetupFact({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) => Row( + children: [ + SizedBox(width: 94, child: _HudLabel(label)), + Expanded(child: _MonoText(value, color: textPrimary, size: 10)), + ], + ); +} + +class _ConfigurationUnavailable extends StatelessWidget { + const _ConfigurationUnavailable(); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: textSecondary.withValues(alpha: .08), + border: Border.all(color: panelBorder), + borderRadius: BorderRadius.circular(8), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _HudLabel('VEHICLE CONFIGURATION UNAVAILABLE'), + SizedBox(height: 3), + Text( + 'Unavailable until verified binding, compatible schema, command authority, and safe activation are available.', + ), + ], + ), + ); +} + +/// Coalesces pointer movement to the next frame and publishes it only to the +/// map-preview provider. The command reducer never receives hover traffic. +class _TacticalPointerInput extends ConsumerStatefulWidget { + const _TacticalPointerInput({ + required this.mode, + required this.onPoint, + required this.onStrokeComplete, + required this.onCancel, + }); + + final TacticalMode mode; + final ValueChanged onPoint; + final ValueChanged> onStrokeComplete; + final VoidCallback onCancel; + + @override + ConsumerState<_TacticalPointerInput> createState() => + _TacticalPointerInputState(); +} + +class _TacticalPointerInputState extends ConsumerState<_TacticalPointerInput> { + // About one logical pixel on a typical desktop map. We coalesce updates to + // one frame, then retain a bounded high-detail preview so slow curves do not + // turn into coarse straight segments. + static const _maximumStrokeSamples = 512; + static const _minimumSampleDistanceSquared = .000001; + + LocalScenePoint? _pending; + final List _stroke = []; + int? _activePrimaryPointer; + bool _frameScheduled = false; + late final TacticalPointerPreviewController _previewController; + + @override + void initState() { + super.initState(); + _previewController = ref.read(tacticalPointerPreviewProvider.notifier); + } + + @override + Widget build(BuildContext context) => Listener( + behavior: HitTestBehavior.translucent, + onPointerMove: _queuePreview, + onPointerHover: _queueHoverPreview, + onPointerDown: (event) { + if (event.buttons == kSecondaryButton) { + _activePrimaryPointer = null; + _clearPreview(); + widget.onCancel(); + return; + } + if (event.buttons != kPrimaryButton) { + return; + } + final point = _pointFor(event.position); + if (point != null) { + _activePrimaryPointer = event.pointer; + _stroke + ..clear() + ..add(point); + _previewController.show(point, stroke: _stroke); + } + }, + onPointerUp: (event) { + if (event.pointer != _activePrimaryPointer) { + return; + } + _activePrimaryPointer = null; + _flushPending(); + final point = _pointFor(event.position); + if (point == null) { + _clearPreview(); + return; + } + _appendDistinct(point); + final stroke = List.of(_stroke); + final mode = widget.mode; + if (mode is TacticalPencilMode) { + widget.onStrokeComplete(stroke); + } else if (mode is TacticalMarkerMode) { + widget.onPoint(point); + } + _clearPreview(); + }, + onPointerCancel: (event) { + if (event.pointer == _activePrimaryPointer) { + _activePrimaryPointer = null; + _clearPreview(); + } + }, + child: const ExcludeSemantics(child: SizedBox.expand()), + ); + + @override + void dispose() { + // A mode transition clears the provider before this input unmounts. Do not + // mutate a Riverpod provider from disposal; it can run while the widget + // tree is being rebuilt. Clearing the pending sample prevents a queued + // frame callback from restoring the preview after unmount. + _pending = null; + super.dispose(); + } + + void _clearPreview() { + _pending = null; + _stroke.clear(); + _previewController.clear(); + } + + void _queuePreview(PointerMoveEvent event) { + if (event.pointer != _activePrimaryPointer) { + return; + } + _schedulePreview(_pointFor(event.position)); + } + + void _queueHoverPreview(PointerHoverEvent event) { + if (_activePrimaryPointer != null || widget.mode is! TacticalMarkerMode) { + return; + } + _schedulePreview(_pointFor(event.position)); + } + + void _schedulePreview(LocalScenePoint? point) { + _pending = point; + if (_frameScheduled || _pending == null) { + return; + } + _frameScheduled = true; + SchedulerBinding.instance.scheduleFrameCallback((_) { + _frameScheduled = false; + final point = _pending; + _pending = null; + if (!mounted || point == null) { + return; + } + if (_activePrimaryPointer != null && + widget.mode is TacticalPencilMode && + _stroke.isNotEmpty) { + _appendDistinct(point); + } + _previewController.show(point, stroke: _stroke); + }); + } + + void _flushPending() { + final point = _pending; + _pending = null; + if (point != null && widget.mode is TacticalPencilMode) { + _appendDistinct(point); + } + } + + void _appendDistinct(LocalScenePoint point) { + if (_stroke.isEmpty) { + _stroke.add(point); + return; + } + final previous = _stroke.last; + final dx = previous.x - point.x; + final dy = previous.y - point.y; + if (dx * dx + dy * dy < _minimumSampleDistanceSquared) { + return; + } + if (_stroke.length >= _maximumStrokeSamples) { + _stroke[_stroke.length - 1] = point; + } else { + _stroke.add(point); + } + } + + LocalScenePoint? _pointFor(Offset globalPosition) { + final box = context.findRenderObject() as RenderBox?; + if (box == null || box.size.isEmpty) { + return null; + } + final local = box.globalToLocal(globalPosition); + return LocalScenePoint( + x: (local.dx / box.size.width).clamp(0.0, 1.0), + y: (local.dy / box.size.height).clamp(0.0, 1.0), + ); + } +} + +class _TopNavigation extends StatelessWidget { + const _TopNavigation({ + required this.sourceLabel, + required this.sourceStatus, + required this.onOpenEventStream, + this.compact = false, + }); + + final bool compact; + final String sourceLabel; + final String sourceStatus; + final VoidCallback onOpenEventStream; + + @override + Widget build(BuildContext context) => _TacticalGlass( + padding: EdgeInsets.symmetric(horizontal: compact ? 14 : 20, vertical: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + const _BrandMark(), + const SizedBox(width: 24), + const _HeaderFact(label: 'MODE', value: 'TACTICAL COMMAND'), + const SizedBox(width: 24), + const _HeaderFact(label: 'DATA', value: 'LOCAL SCENE'), + const SizedBox(width: 24), + _HeaderFact(label: 'SOURCE', value: '$sourceLabel · $sourceStatus'), + const SizedBox(width: 16), + TextButton.icon( + onPressed: onOpenEventStream, + icon: const Icon(Icons.subject_outlined, size: 16), + label: const Text('EVENT STREAM'), + ), + const SizedBox(width: 16), + const _PhaseToggle(), + const SizedBox(width: 28), + const _HeaderTimer( + icon: Icons.timer_outlined, + label: 'SCENE TIME', + value: '00:55:08', + ), + const SizedBox(width: 20), + const _HeaderTimer( + icon: Icons.flight_outlined, + label: 'RENDER TIME', + value: '00:36:03', + ), + ], + ), + ), + ); +} + +class _BrandMark extends StatelessWidget { + const _BrandMark(); + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: accentMint.withValues(alpha: .12), + border: Border.all(color: accentMint.withValues(alpha: .6)), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.flight_takeoff, color: accentMint, size: 19), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'KAPTAN', + style: GoogleFonts.inter( + color: textPrimary, + fontSize: 14, + fontWeight: FontWeight.w800, + letterSpacing: 1.4, + ), + ), + const _HudLabel('GROUND SUITE'), + ], + ), + ], + ); +} + +class _HeaderFact extends StatelessWidget { + const _HeaderFact({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _HudLabel(label), + const SizedBox(height: 4), + Text( + value, + style: GoogleFonts.inter( + color: textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ); +} + +class _PhaseToggle extends StatelessWidget { + const _PhaseToggle(); + + @override + Widget build(BuildContext context) { + const tone = accentOrange; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: tone.withValues(alpha: .16), + border: Border.all(color: tone.withValues(alpha: .65)), + borderRadius: BorderRadius.circular(7), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.auto_mode, color: tone, size: 16), + const SizedBox(width: 7), + _HudLabel('PHASE · LOCAL', color: tone), + ], + ), + ); + } +} + +class _HeaderTimer extends StatelessWidget { + const _HeaderTimer({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) => Row( + children: [ + Icon(icon, color: textSecondary, size: 16), + const SizedBox(width: 7), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _HudLabel(label), + const SizedBox(height: 3), + _MonoText(value, color: textPrimary, size: 12), + ], + ), + ], + ); +} + +class _FpvPanel extends StatelessWidget { + const _FpvPanel({required this.selectedUnit}); + + final _DroneData selectedUnit; + + @override + Widget build(BuildContext context) => _TacticalGlass( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 7, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const _HudLabel('SIMULATED CAMERA CONCEPT'), + _Token(label: selectedUnit.id, tone: accentMint), + _Token(label: 'NOT VIDEO', tone: accentOrange), + ], + ), + const SizedBox(height: 9), + Expanded( + child: Semantics( + label: 'Simulated camera concept. Not vehicle video.', + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: ExcludeSemantics( + child: Stack( + fit: StackFit.expand, + children: [ + Image.network( + _fpvPlaceholder, + fit: BoxFit.cover, + cacheWidth: 1280, + filterQuality: FilterQuality.medium, + loadingBuilder: (context, child, progress) => + progress == null + ? child + : const ColoredBox(color: panelBackground), + errorBuilder: (context, error, stackTrace) => + const ColoredBox( + color: panelBackground, + child: Center( + child: _UnavailableVisual( + icon: Icons.videocam_off_outlined, + label: 'DEMO CAMERA VISUAL UNAVAILABLE', + ), + ), + ), + ), + ColoredBox(color: tacticalBlack.withValues(alpha: .22)), + CustomPaint(painter: _FpvHudPainter()), + const Positioned( + left: 16, + top: 18, + child: _FpvScale( + label: 'SPD', + value: '34.2', + unit: 'km/h', + ), + ), + const Positioned( + right: 16, + top: 18, + child: _FpvScale( + label: 'ALT', + value: '252', + unit: 'm', + alignEnd: true, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); +} + +class _FpvScale extends StatelessWidget { + const _FpvScale({ + required this.label, + required this.value, + required this.unit, + this.alignEnd = false, + }); + + final String label; + final String value; + final String unit; + final bool alignEnd; + + @override + Widget build(BuildContext context) { + const tone = accentMint; + return Column( + crossAxisAlignment: alignEnd + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + _HudLabel(label, color: tone), + const SizedBox(height: 6), + _MonoText(value, color: tone, size: 22, weight: FontWeight.w500), + _HudLabel(unit, color: tone), + ], + ); + } +} + +class _UnitCommandTray extends StatelessWidget { + const _UnitCommandTray({ + required this.selectedUnit, + required this.selectedUnitIds, + required this.onUnitSelected, + required this.onAddUnit, + required this.onOpenSetup, + required this.workspaceUnits, + }); + + final _DroneData? selectedUnit; + final Set selectedUnitIds; + final ValueChanged onUnitSelected; + final VoidCallback onAddUnit; + final VoidCallback onOpenSetup; + final List workspaceUnits; + + @override + Widget build(BuildContext context) { + if (selectedUnitIds.isEmpty) { + return _FleetSummaryConsole( + localUnitCount: workspaceUnits.length, + onAddUnit: onAddUnit, + ); + } + if (selectedUnitIds.length > 1) { + return _MultiUnitConsole( + selectedCount: selectedUnitIds.length, + onClear: () => onUnitSelected(''), + ); + } + if (selectedUnit == null) { + final local = workspaceUnits.where( + (unit) => selectedUnitIds.contains(unit.localUnitId), + ); + return _LocalWorkspaceUnitConsole( + unit: local.isEmpty ? null : local.single, + onAddUnit: onAddUnit, + onOpenSetup: onOpenSetup, + ); + } + final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; + final selectorHeight = textScale >= 1.25 ? 112.0 : 54.0; + return _TacticalGlass( + padding: const EdgeInsets.all(10), + borderRadius: BorderRadius.circular(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.view_in_ar_outlined, + color: accentMint, + size: 16, + ), + const SizedBox(width: 7), + const Expanded(child: _HudLabel('LOCAL SCENE SELECTION')), + const _Token(label: 'SIMULATED', tone: textSecondary), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: selectorHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _fleet.length, + separatorBuilder: (context, index) => const SizedBox(width: 7), + itemBuilder: (context, index) => _UnitSelectorCard( + data: _fleet[index], + selected: _fleet[index].id == selectedUnit!.id, + deployed: false, + onTap: () => onUnitSelected(_fleet[index].id), + ), + ), + ), + const SizedBox(height: 8), + _LocalScenePreviewBar(unit: selectedUnit!), + ], + ), + ); + } +} + +class _FleetSummaryConsole extends StatelessWidget { + const _FleetSummaryConsole({ + required this.localUnitCount, + required this.onAddUnit, + }); + + final int localUnitCount; + final VoidCallback onAddUnit; + + @override + Widget build(BuildContext context) => _TacticalGlass( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + const Icon(Icons.groups_2_outlined, color: accentMint), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _HudLabel('LOCAL FLEET SUMMARY'), + const SizedBox(height: 4), + _MonoText( + '$localUnitCount LOCAL UNIT${localUnitCount == 1 ? '' : 'S'}', + color: textPrimary, + size: 12, + weight: FontWeight.w700, + ), + ], + ), + ), + _UnitActionButton( + key: const Key('add-local-unit-console'), + label: 'ADD UNIT', + icon: Icons.add, + foreground: tacticalBlack, + background: accentMint, + onPressed: onAddUnit, + ), + ], + ), + ); +} + +class _MultiUnitConsole extends StatelessWidget { + const _MultiUnitConsole({required this.selectedCount, required this.onClear}); + + final int selectedCount; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) => _TacticalGlass( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + const Icon(Icons.select_all_outlined, color: accentMint), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _HudLabel('LOCAL GROUP SELECTION'), + const SizedBox(height: 4), + _MonoText( + '$selectedCount UNITS SELECTED', + color: textPrimary, + size: 12, + weight: FontWeight.w700, + ), + ], + ), + ), + _UnitActionButton( + label: 'CLEAR', + icon: Icons.close, + foreground: textPrimary, + background: tacticalTransparent, + onPressed: onClear, + ), + ], + ), + ); +} + +class _LocalWorkspaceUnitConsole extends StatelessWidget { + const _LocalWorkspaceUnitConsole({ + required this.unit, + required this.onAddUnit, + required this.onOpenSetup, + }); + + final FleetUnit? unit; + final VoidCallback onAddUnit; + final VoidCallback onOpenSetup; + + @override + Widget build(BuildContext context) => _TacticalGlass( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + const Icon(Icons.flight_outlined, color: accentMint), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _HudLabel('LOCAL WORKSPACE UNIT'), + const SizedBox(height: 4), + _MonoText( + unit?.displayName ?? 'UNBOUND UNIT', + color: textPrimary, + size: 12, + weight: FontWeight.w700, + ), + const SizedBox(height: 3), + _HudLabel( + '${unit?.definition.name.toUpperCase() ?? 'LOCAL UNIT'} · LOCAL SETUP', + ), + const SizedBox(height: 3), + const _HudLabel('NO SOURCE · NO POSITION · NO AUTHORITY'), + ], + ), + ), + Wrap( + spacing: 8, + children: [ + _UnitActionButton( + label: 'SET UP', + icon: Icons.tune_outlined, + foreground: tacticalBlack, + background: accentMint, + onPressed: onOpenSetup, + ), + _UnitActionButton( + label: 'ADD', + icon: Icons.add, + foreground: textPrimary, + background: tacticalTransparent, + onPressed: onAddUnit, + ), + ], + ), + ], + ), + ); +} + +class _UnitSelectorCard extends StatelessWidget { + const _UnitSelectorCard({ + required this.data, + required this.selected, + required this.deployed, + required this.onTap, + }); + + final _DroneData data; + final bool selected; + final bool deployed; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + selected: selected, + label: '${data.id}, simulated local scene unit', + child: InkWell( + key: Key('unit-card-${data.id}'), + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 112, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7), + decoration: BoxDecoration( + color: selected ? accentMint.withValues(alpha: .1) : panelBackground, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: selected ? accentMint : panelBorder), + ), + child: Row( + children: [ + Icon( + Icons.flight, + color: selected ? accentMint : data.statusColor, + size: 18, + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _MonoText(data.id, color: textPrimary, size: 8), + const SizedBox(height: 2), + Text( + 'LOCAL PREVIEW', + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + style: GoogleFonts.inter( + color: textSecondary, + fontSize: 7, + fontWeight: FontWeight.w700, + letterSpacing: .45, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); +} + +class _LocalScenePreviewBar extends StatelessWidget { + const _LocalScenePreviewBar({required this.unit}); + + final _DroneData unit; + + @override + Widget build(BuildContext context) => Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MonoText(unit.id, color: textPrimary, size: 10), + const SizedBox(height: 3), + const _HudLabel('LOCAL VISUAL PREVIEW · NO VEHICLE ACTION'), + ], + ), + ), + const Icon(Icons.lock_outline, color: textSecondary, size: 17), + ], + ); +} + +class _UnitActionButton extends StatelessWidget { + const _UnitActionButton({ + super.key, + required this.label, + required this.icon, + required this.foreground, + required this.background, + required this.onPressed, + }); + + final String label; + final IconData icon; + final Color foreground; + final Color background; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) => SizedBox( + height: 30, + child: TextButton.icon( + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: foreground, + backgroundColor: background, + padding: const EdgeInsets.symmetric(horizontal: 8), + side: BorderSide(color: foreground.withValues(alpha: .7)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + ), + icon: Icon(icon, size: 14), + label: Text( + label, + style: GoogleFonts.inter( + color: foreground, + fontSize: 8, + fontWeight: FontWeight.w800, + letterSpacing: .45, + ), + ), + ), + ); +} + +class _TacticalGlass extends StatelessWidget { + const _TacticalGlass({ + required this.child, + this.padding = EdgeInsets.zero, + this.borderRadius = const BorderRadius.all(Radius.circular(12)), + }); + + final Widget child; + final EdgeInsets padding; + final BorderRadius borderRadius; + + @override + Widget build(BuildContext context) { + final highContrast = MediaQuery.maybeOf(context)?.highContrast ?? false; + final surface = Container( + padding: padding, + decoration: BoxDecoration( + color: highContrast ? const Color(0xFF12141A) : panelBackground, + borderRadius: borderRadius, + border: Border.all( + color: highContrast ? textPrimary : panelBorder, + width: highContrast ? 2 : 1, + ), + ), + child: child, + ); + if (highContrast) { + return RepaintBoundary(child: surface); + } + return RepaintBoundary( + child: ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: surface, + ), + ), + ); + } +} + +class _Token extends StatelessWidget { + const _Token({required this.label, required this.tone}); + + final String label; + final Color tone; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: tone.withValues(alpha: .12), + border: Border.all(color: tone.withValues(alpha: .55)), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [_HudLabel(label, color: tone)], + ), + ); +} + +class _HudLabel extends StatelessWidget { + const _HudLabel(this.value, {this.color}); + + final String value; + final Color? color; + + @override + Widget build(BuildContext context) => Text( + value, + style: GoogleFonts.inter( + color: color ?? textSecondary, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: .85, + ), + ); +} + +class _MonoText extends StatelessWidget { + const _MonoText( + this.value, { + this.color, + this.size = 10, + this.weight = FontWeight.w400, + }); + + final String value; + final Color? color; + final double size; + final FontWeight weight; + + @override + Widget build(BuildContext context) => Text( + value, + style: GoogleFonts.robotoMono( + color: color ?? textSecondary, + fontSize: size, + fontWeight: weight, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); +} + +class _DroneData { + const _DroneData( + this.id, + this.speed, + this.altitude, + this.status, + this.statusColor, + this.battery, + ); + + final String id; + final String speed; + final String altitude; + final String status; + final Color statusColor; + final double battery; +} + +class _MapGridPainter extends CustomPainter { + const _MapGridPainter(); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = accentMint.withValues(alpha: .07) + ..strokeWidth = 1; + const step = 82.0; + for (var x = 0.0; x < size.width; x += step) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + } + for (var y = 0.0; y < size.height; y += step) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + } + + @override + bool shouldRepaint(covariant _MapGridPainter oldDelegate) => false; +} + +class _DemoMapOverlayPainter extends CustomPainter { + const _DemoMapOverlayPainter({ + required this.drawings, + required this.markers, + required this.draftPencil, + required this.draftMarker, + required this.pointerPreview, + required this.previewStroke, + }); + + final List drawings; + final List markers; + final TacticalPencilMode? draftPencil; + final TacticalMarkerMode? draftMarker; + final LocalScenePoint? pointerPreview; + final List previewStroke; + + @override + void paint(Canvas canvas, Size size) { + for (final drawing in drawings) { + _drawDrawing( + canvas, + size, + drawing.points, + color: _pencilColor( + drawing.hue, + drawing.saturation, + drawing.brightness, + ), + thickness: drawing.thickness, + ); + } + final draft = draftPencil; + if (draft != null && draft.keyboardPoints.isNotEmpty) { + _drawDrawing( + canvas, + size, + draft.keyboardPoints, + color: _pencilColor(draft.hue, draft.saturation, draft.brightness), + thickness: draft.thickness, + draft: true, + ); + } + if (draft != null && previewStroke.isNotEmpty) { + _drawDrawing( + canvas, + size, + previewStroke, + color: _pencilColor(draft.hue, draft.saturation, draft.brightness), + thickness: draft.thickness, + draft: true, + ); + } + final preview = pointerPreview; + if (preview != null && draft != null) { + final offset = Offset(preview.x * size.width, preview.y * size.height); + canvas.drawCircle( + offset, + 8, + Paint() + ..color = _pencilColor( + draft.hue, + draft.saturation, + draft.brightness, + ).withValues(alpha: .9) + ..style = PaintingStyle.stroke + ..strokeWidth = draft.thickness, + ); + } + final marker = draftMarker; + if (marker != null && preview != null && marker.pendingPoint == null) { + _drawMarkerPreview( + canvas, + size, + preview, + color: _markerColor(marker.hue, marker.saturation, marker.brightness), + ); + } + if (marker?.pendingPoint case final point?) { + _drawMarkerPreview( + canvas, + size, + point, + color: _markerColor(marker!.hue, marker.saturation, marker.brightness), + pending: true, + ); + } + } + + void _drawMarkerPreview( + Canvas canvas, + Size size, + LocalScenePoint point, { + required Color color, + bool pending = false, + }) { + final offset = Offset(point.x * size.width, point.y * size.height); + final paint = Paint() + ..color = color.withValues(alpha: pending ? .95 : .72) + ..style = PaintingStyle.fill; + final pin = Path() + ..moveTo(offset.dx, offset.dy + 15) + ..cubicTo( + offset.dx - 11, + offset.dy + 4, + offset.dx - 9, + offset.dy - 13, + offset.dx, + offset.dy - 13, + ) + ..cubicTo( + offset.dx + 9, + offset.dy - 13, + offset.dx + 11, + offset.dy + 4, + offset.dx, + offset.dy + 15, + ) + ..close(); + canvas.drawPath(pin, paint); + canvas.drawCircle( + Offset(offset.dx, offset.dy - 3), + 3, + Paint()..color = tacticalBlack, + ); + } + + void _drawDrawing( + Canvas canvas, + Size size, + List points, { + required Color color, + required double thickness, + bool draft = false, + }) { + final paint = Paint() + ..color = color.withValues(alpha: draft ? .75 : .9) + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..strokeWidth = draft ? math.max(1, thickness * .75) : thickness; + final offsets = points + .map((point) => Offset(point.x * size.width, point.y * size.height)) + .toList(growable: false); + if (offsets.isEmpty) { + return; + } + final path = Path()..moveTo(offsets.first.dx, offsets.first.dy); + for (final offset in offsets.skip(1)) { + path.lineTo(offset.dx, offset.dy); + } + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _DemoMapOverlayPainter oldDelegate) => + !identical(drawings, oldDelegate.drawings) || + !identical(markers, oldDelegate.markers) || + draftPencil != oldDelegate.draftPencil || + draftMarker != oldDelegate.draftMarker || + pointerPreview != oldDelegate.pointerPreview || + !identical(previewStroke, oldDelegate.previewStroke); +} + +class _FpvHudPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = accentMint + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); + final center = Offset(size.width / 2, size.height / 2); + canvas.drawCircle(center, 15, paint); + canvas.drawLine( + Offset(center.dx - 54, center.dy), + Offset(center.dx - 18, center.dy), + paint, + ); + canvas.drawLine( + Offset(center.dx + 18, center.dy), + Offset(center.dx + 54, center.dy), + paint, + ); + canvas.drawLine( + Offset(center.dx, center.dy - 9), + Offset(center.dx, center.dy + 9), + paint, + ); + + final textPainter = TextPainter(textDirection: TextDirection.ltr); + for (var index = -3; index <= 3; index++) { + if (index == 0) { + continue; + } + final y = center.dy - index * 24; + final width = 18 + index.abs() * 7.0; + canvas.drawLine( + Offset(center.dx - width, y), + Offset(center.dx + width, y), + paint, + ); + textPainter.text = TextSpan( + text: '${index * 10}', + style: GoogleFonts.robotoMono(color: accentMint, fontSize: 8), + ); + textPainter.layout(); + textPainter.paint(canvas, Offset(center.dx + width + 5, y - 5)); + } + + final scalePaint = paint..color = accentMint.withValues(alpha: .62); + final left = 70.0; + final right = size.width - 70; + for (var index = 0; index < 7; index++) { + final y = 62 + index * ((size.height - 124) / 6); + canvas.drawLine(Offset(left - 8, y), Offset(left + 8, y), scalePaint); + canvas.drawLine(Offset(right - 8, y), Offset(right + 8, y), scalePaint); + } + } + + @override + bool shouldRepaint(covariant _FpvHudPainter oldDelegate) => false; +} diff --git a/ground-suite/lib/features/overview/presentation/overview_page.dart b/ground-suite/lib/features/overview/presentation/overview_page.dart deleted file mode 100644 index e9250a8..0000000 --- a/ground-suite/lib/features/overview/presentation/overview_page.dart +++ /dev/null @@ -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.'), - ], - ), - ), - ], - ); -} diff --git a/ground-suite/lib/main.dart b/ground-suite/lib/main.dart index 1a337a8..bd40337 100644 --- a/ground-suite/lib/main.dart +++ b/ground-suite/lib/main.dart @@ -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()); } diff --git a/ground-suite/lib/shared/widgets/surfaces.dart b/ground-suite/lib/shared/widgets/surfaces.dart index bd3d5d3..fb603ef 100644 --- a/ground-suite/lib/shared/widgets/surfaces.dart +++ b/ground-suite/lib/shared/widgets/surfaces.dart @@ -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( diff --git a/ground-suite/test/widget_test.dart b/ground-suite/test/widget_test.dart index fa2c643..de4b165 100644 --- a/ground-suite/test/widget_test.dart +++ b/ground-suite/test/widget_test.dart @@ -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(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(), + ); + 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(find.byType(MaterialApp)).themeMode, ThemeMode.dark, ); + expect(Theme.of(context).brightness, Brightness.dark); + expect(Theme.of(context).cardColor, panelBackground); + expect( + tester + .widget( + 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(), + ); + }); + + 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(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); }); }