4695 lines
140 KiB
Dart
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;
|
|
}
|