Files
kaptan/ground-suite/lib/features/command/presentation/tactical_command_page.dart
T

4695 lines
140 KiB
Dart

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 = <String, Alignment>{
'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<TacticalCommandPage> createState() =>
_TacticalCommandPageState();
}
class _TacticalCommandPageState extends ConsumerState<TacticalCommandPage> {
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<void> _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<bool> _confirmClearLocalOverlays() async =>
await showDialog<bool>(
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<LocalScenePoint> 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<String> 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<String> 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<String> selectedUnitIds;
final TacticalMode mode;
final TacticalPanel? panel;
final List<LocalSceneDrawing> drawings;
final List<LocalSceneMarker> markers;
final String? selectedDrawingId;
final String? selectedMarkerId;
final Set<TacticalMapOverlay> 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<TacticalSceneRenderState>(
(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<List<LocalScenePoint>> 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<String> 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<String> _layerNames(Set<TacticalMapOverlay> 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<String> layers;
final List<LocalSceneDrawing> drawings;
final List<LocalSceneMarker> markers;
final TacticalPencilMode? draftPencil;
final TacticalMarkerMode? draftMarker;
final List<FleetUnit> localUnits;
final Set<String> selectedUnitIds;
final ValueChanged<String> onUnitSelected;
final void Function(LocalScenePoint start, LocalScenePoint end)
onSelectionRect;
final ValueChanged<String> 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<FleetUnit> workspaceUnits;
@override
Widget build(BuildContext context) {
final local = workspaceUnits.cast<FleetUnit?>().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<FleetUnit> workspaceUnits;
final TacticalSceneRenderState tactical;
final ValueChanged<String> 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<String> layers;
final void Function(String layer, bool enabled) onLayerChanged;
final _DroneData? selectedUnit;
final Set<String> selectedUnitIds;
final ValueChanged<String> onUnitSelected;
final TacticalSceneRenderState tactical;
final List<FleetUnit> 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<String> layers;
final void Function(String layer, bool enabled) onLayerChanged;
final _DroneData? selectedUnit;
final Set<String> selectedUnitIds;
final ValueChanged<String> onUnitSelected;
final TacticalSceneRenderState tactical;
final List<FleetUnit> 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<String> 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<String> 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<void> _showActionSheet(
BuildContext context, {
required String title,
required List<_MenuAction> actions,
}) => showModalBottomSheet<void>(
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<void> _showViewSheet(
BuildContext context,
TacticalSceneRenderState tactical,
Set<String> 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<List<LocalScenePoint>> 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<String> 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<List<LocalScenePoint>> 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<DismissIntent>(
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<double> 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<String> 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<DismissIntent>(
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<String> 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<double> 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<VehicleDefinition> onSelect;
final VoidCallback onPrepare;
final VoidCallback onClose;
@override
State<_HangarSelection> createState() => _HangarSelectionState();
}
class _HangarSelectionState extends State<_HangarSelection> {
late final List<FocusNode> _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<String> 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<String> 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<String> 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<LocalScenePoint> onPoint;
final ValueChanged<List<LocalScenePoint>> 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<LocalScenePoint> _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<LocalScenePoint>.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<String> selectedUnitIds;
final ValueChanged<String> onUnitSelected;
final VoidCallback onAddUnit;
final VoidCallback onOpenSetup;
final List<FleetUnit> 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<LocalSceneDrawing> drawings;
final List<LocalSceneMarker> markers;
final TacticalPencilMode? draftPencil;
final TacticalMarkerMode? draftMarker;
final LocalScenePoint? pointerPreview;
final List<LocalScenePoint> 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<LocalScenePoint> 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;
}