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; }