import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../fleet/domain/fleet_unit.dart'; import '../../vehicle_workspace/domain/vehicle_definition.dart'; /// A non-modal tactical side panel. Tool state remains in [TacticalMode], so a /// panel can never silently become a map action. enum TacticalPanel { fleetRoster, unitHangar, unitSetup } /// Exactly one local map interaction can be active at a time. sealed class TacticalMode { const TacticalMode(); } class TacticalBrowseMode extends TacticalMode { const TacticalBrowseMode(); } /// The operator's uncommitted local-unit setup. This is intentionally a panel /// draft rather than a map tool: choosing a category never creates, connects, /// or configures a vehicle. class UnitCreationDraft { const UnitCreationDraft({ required this.definition, required this.displayName, }); final VehicleDefinition definition; final String displayName; UnitCreationDraft copyWith({String? displayName}) => UnitCreationDraft( definition: definition, displayName: displayName ?? this.displayName, ); } /// Session-local freehand drawing tool. It never emits a vehicle command. class TacticalPencilMode extends TacticalMode { TacticalPencilMode({ this.hue = 164, this.saturation = .78, this.brightness = .94, this.thickness = 3, List keyboardPoints = const [], }) : keyboardPoints = List.unmodifiable(keyboardPoints); final double hue; final double saturation; final double brightness; final double thickness; final List keyboardPoints; } /// A local, freehand mark. It deliberately has no map pin or label: Pencil is /// continuous ink, not a point-placement tool. class LocalSceneDrawing { LocalSceneDrawing({ required this.id, required this.hue, required this.saturation, required this.brightness, required this.thickness, required List points, }) : points = List.unmodifiable(points); final String id; final double hue; final double saturation; final double brightness; final double thickness; final List points; } /// A local point annotation. It is kept distinct from [LocalSceneDrawing] so /// an accidental Pencil click can never become a named marker. class TacticalMarkerMode extends TacticalMode { const TacticalMarkerMode({ this.hue = 26, this.saturation = .68, this.brightness = .98, this.pendingPoint, }); final double hue; final double saturation; final double brightness; final LocalScenePoint? pendingPoint; } class LocalSceneMarker { const LocalSceneMarker({ required this.id, required this.name, required this.point, required this.hue, required this.saturation, required this.brightness, }); final String id; final String name; final LocalScenePoint point; final double hue; final double saturation; final double brightness; } class TacticalUiState { const TacticalUiState({ required this.selectedUnitIds, required this.mode, required this.panel, required this.unitDraft, required this.drawings, required this.markers, required this.selectedDrawingId, required this.selectedMarkerId, required this.overlays, }); factory TacticalUiState.initial() => const TacticalUiState( selectedUnitIds: {'DEMO-1021'}, mode: TacticalBrowseMode(), panel: null, unitDraft: null, drawings: [], markers: [], selectedDrawingId: null, selectedMarkerId: null, overlays: {TacticalMapOverlay.drones}, ); final Set selectedUnitIds; final TacticalMode mode; final TacticalPanel? panel; final UnitCreationDraft? unitDraft; final List drawings; final List markers; final String? selectedDrawingId; final String? selectedMarkerId; final Set overlays; TacticalUiState copyWith({ Set? selectedUnitIds, TacticalMode? mode, TacticalPanel? panel, bool clearPanel = false, UnitCreationDraft? unitDraft, bool clearUnitDraft = false, List? drawings, List? markers, String? selectedDrawingId, String? selectedMarkerId, bool clearSelectedDrawing = false, bool clearSelectedMarker = false, Set? overlays, }) => TacticalUiState( selectedUnitIds: selectedUnitIds == null ? this.selectedUnitIds : Set.unmodifiable(selectedUnitIds), mode: mode ?? this.mode, panel: clearPanel ? null : panel ?? this.panel, unitDraft: clearUnitDraft ? null : unitDraft ?? this.unitDraft, drawings: drawings == null ? this.drawings : List.unmodifiable(drawings), markers: markers == null ? this.markers : List.unmodifiable(markers), selectedDrawingId: clearSelectedDrawing ? null : selectedDrawingId ?? this.selectedDrawingId, selectedMarkerId: clearSelectedMarker ? null : selectedMarkerId ?? this.selectedMarkerId, overlays: overlays == null ? this.overlays : Set.unmodifiable(overlays), ); } /// Only data-backed scene layers belong here. Synthetic sector, route, and /// range-ring artwork is intentionally not part of the tactical workspace. enum TacticalMapOverlay { drones } final tacticalControllerProvider = NotifierProvider( TacticalController.new, ); /// Deterministic reducer for session-local tactical interaction state. /// /// Agreed action taxonomy: /// - unit selection and placement affect only a local workspace; /// - Pencil affects only [LocalSceneDrawing] values; /// - Marker affects only [LocalSceneMarker] values; /// - overlay actions affect only local rendering. /// /// This controller has no transport dependency and no method can emit MAVLink /// or create a live vehicle identity. class TacticalController extends Notifier { int _nextDrawing = 1; int _nextMarker = 1; @override TacticalUiState build() => TacticalUiState.initial(); void selectUnit(String unitId, {bool toggle = false}) { final selected = toggle ? {...state.selectedUnitIds} : {}; if (toggle && selected.contains(unitId)) { selected.remove(unitId); } else { selected.add(unitId); } state = state.copyWith( selectedUnitIds: selected, mode: const TacticalBrowseMode(), clearSelectedDrawing: true, clearSelectedMarker: true, clearPanel: true, ); } /// Replaces the local visual selection after a drag-box gesture. This is a /// scene-only selection and cannot establish a source or vehicle binding. void selectUnits(Iterable unitIds) => state = state.copyWith( selectedUnitIds: Set.unmodifiable(unitIds.toSet()), mode: const TacticalBrowseMode(), clearSelectedDrawing: true, clearSelectedMarker: true, clearPanel: true, ); void clearSelection() => state = state.copyWith( selectedUnitIds: const {}, clearSelectedDrawing: true, clearSelectedMarker: true, ); void showFleetRoster() => state = state.copyWith(panel: TacticalPanel.fleetRoster); void closePanel() => state = state.copyWith(clearPanel: true); /// Opens the horizontal RTS hangar. The map remains in Browse mode behind /// it; the hangar never becomes a pointer-placement tool. void showUnitHangar() => state = state.copyWith( mode: const TacticalBrowseMode(), panel: TacticalPanel.unitHangar, clearUnitDraft: true, ); void chooseUnitDefinition( VehicleDefinition definition, { required String displayName, }) => state = state.copyWith( panel: TacticalPanel.unitHangar, unitDraft: UnitCreationDraft( definition: definition, displayName: displayName, ), ); void updateUnitDraftName(String displayName) { final draft = state.unitDraft; if (draft == null) { return; } state = state.copyWith(unitDraft: draft.copyWith(displayName: displayName)); } void beginUnitSetup() { if (state.unitDraft == null) { return; } state = state.copyWith(panel: TacticalPanel.unitSetup); } /// Returns to the catalog without discarding the selected category or its /// local name draft. Closing the hangar is the explicit discard boundary. void returnToUnitHangar() => state = state.copyWith(panel: TacticalPanel.unitHangar); /// Opens setup for the one already-selected local workspace unit. The ID is /// supplied by the presentation layer after it verifies the unit exists in /// its workspace; this reducer only accepts a matching tactical selection. void showUnitSetupForSelectedLocalUnit(String localUnitId) { if (state.unitDraft != null || state.selectedUnitIds.length != 1 || !state.selectedUnitIds.contains(localUnitId)) { return; } state = state.copyWith( mode: const TacticalBrowseMode(), panel: TacticalPanel.unitSetup, ); } void closeUnitHangar() => state = state.copyWith(clearPanel: true, clearUnitDraft: true); void completeUnitPlacement(String unitId) => state = state.copyWith( selectedUnitIds: {unitId}, mode: const TacticalBrowseMode(), clearPanel: true, clearUnitDraft: true, ); void beginPencil() => state = state.copyWith( mode: TacticalPencilMode(), selectedUnitIds: const {}, clearSelectedDrawing: true, clearSelectedMarker: true, clearPanel: true, clearUnitDraft: true, ); void configurePencil({ double? hue, double? saturation, double? brightness, double? thickness, }) { final mode = state.mode; if (mode is! TacticalPencilMode) { return; } state = state.copyWith( mode: TacticalPencilMode( hue: hue ?? mode.hue, saturation: saturation ?? mode.saturation, brightness: brightness ?? mode.brightness, thickness: thickness ?? mode.thickness, keyboardPoints: mode.keyboardPoints, ), ); } /// Accessible alternative to a direct pointer trace. Keyboard users add /// points at a movable scene cursor; it never exposes a grid or location. void addKeyboardPencilPoint(LocalScenePoint point) { final mode = state.mode; if (mode is! TacticalPencilMode) { return; } state = state.copyWith( mode: TacticalPencilMode( hue: mode.hue, saturation: mode.saturation, brightness: mode.brightness, thickness: mode.thickness, keyboardPoints: [...mode.keyboardPoints, point], ), ); } bool get canFinishKeyboardPencil { final mode = state.mode; return mode is TacticalPencilMode && mode.keyboardPoints.length >= 2; } bool finishKeyboardPencil() { final mode = state.mode; if (mode is! TacticalPencilMode || !canFinishKeyboardPencil) { return false; } _commitDrawing( hue: mode.hue, saturation: mode.saturation, brightness: mode.brightness, thickness: mode.thickness, points: mode.keyboardPoints, ); return true; } /// Commits a direct press-drag-release map gesture. Preview samples stay in /// the input widget; only the simplified stroke becomes durable local scene /// geometry here. bool commitPencilStroke(List points) { final mode = state.mode; if (mode is! TacticalPencilMode) { return false; } if (points.length < 2) { return false; } _commitDrawing( hue: mode.hue, saturation: mode.saturation, brightness: mode.brightness, thickness: mode.thickness, points: _decimateStroke(points), ); return true; } void beginMarker() => state = state.copyWith( mode: const TacticalMarkerMode(), selectedUnitIds: const {}, clearSelectedDrawing: true, clearSelectedMarker: true, clearPanel: true, clearUnitDraft: true, ); void configureMarker({double? hue, double? saturation, double? brightness}) { final mode = state.mode; if (mode is! TacticalMarkerMode) { return; } state = state.copyWith( mode: TacticalMarkerMode( hue: hue ?? mode.hue, saturation: saturation ?? mode.saturation, brightness: brightness ?? mode.brightness, pendingPoint: mode.pendingPoint, ), ); } void chooseMarkerPoint(LocalScenePoint point) { final mode = state.mode; if (mode is! TacticalMarkerMode || mode.pendingPoint != null) { return; } state = state.copyWith( mode: TacticalMarkerMode( hue: mode.hue, saturation: mode.saturation, brightness: mode.brightness, pendingPoint: point, ), ); } /// Returns false when the label is not acceptable. Labels are local plain /// text only, deliberately bounded before they can reach a map renderer. bool commitMarkerName(String value) { final mode = state.mode; final name = value.trim(); if (mode is! TacticalMarkerMode || mode.pendingPoint == null || name.isEmpty || name.runes.length > 48) { return false; } final marker = LocalSceneMarker( id: 'local-marker-${_nextMarker++}', name: name, point: mode.pendingPoint!, hue: mode.hue, saturation: mode.saturation, brightness: mode.brightness, ); state = state.copyWith( markers: [...state.markers, marker], selectedMarkerId: marker.id, clearSelectedDrawing: true, mode: TacticalMarkerMode( hue: mode.hue, saturation: mode.saturation, brightness: mode.brightness, ), ); return true; } /// First cancellation closes an unfinished marker name prompt; the next /// returns to Select. This gives Escape/right click a predictable ladder. bool cancelActiveTool() { final mode = state.mode; if (mode is TacticalMarkerMode && mode.pendingPoint != null) { state = state.copyWith( mode: TacticalMarkerMode( hue: mode.hue, saturation: mode.saturation, brightness: mode.brightness, ), ); return false; } cancelMode(); return true; } void selectDrawing(String drawingId) => state = state.copyWith( selectedUnitIds: const {}, selectedDrawingId: drawingId, clearSelectedMarker: true, mode: const TacticalBrowseMode(), ); void selectMarker(String markerId) => state = state.copyWith( selectedUnitIds: const {}, selectedMarkerId: markerId, clearSelectedDrawing: true, mode: const TacticalBrowseMode(), ); void removeSelectedSceneObject() { final drawing = state.selectedDrawingId; final marker = state.selectedMarkerId; if (drawing != null) { state = state.copyWith( drawings: state.drawings.where((value) => value.id != drawing).toList(), clearSelectedDrawing: true, ); return; } if (marker == null) { return; } state = state.copyWith( markers: state.markers.where((value) => value.id != marker).toList(), clearSelectedMarker: true, ); } void clearSceneObjects() => state = state.copyWith( drawings: const [], markers: const [], clearSelectedDrawing: true, clearSelectedMarker: true, ); void cancelMode() => state = state.copyWith(mode: const TacticalBrowseMode()); void toggleOverlay(TacticalMapOverlay overlay, bool visible) { final overlays = {...state.overlays}; if (visible) { overlays.add(overlay); } else { overlays.remove(overlay); } state = state.copyWith(overlays: overlays); } void resetLocalView() => state = state.copyWith( overlays: {TacticalMapOverlay.drones}, mode: const TacticalBrowseMode(), ); void _commitDrawing({ required double hue, required double saturation, required double brightness, required double thickness, required List points, }) { final drawing = LocalSceneDrawing( id: 'local-drawing-${_nextDrawing++}', hue: hue, saturation: saturation, brightness: brightness, thickness: thickness, points: List.unmodifiable(points), ); state = state.copyWith( drawings: [...state.drawings, drawing], selectedDrawingId: drawing.id, clearSelectedMarker: true, mode: TacticalPencilMode( hue: hue, saturation: saturation, brightness: brightness, thickness: thickness, ), ); } // Preserve enough detail for a visibly hand-drawn line while keeping local // session state bounded. Input preview has its own higher short-lived cap. static const _maximumStrokePoints = 256; List _decimateStroke(List points) { if (points.length <= _maximumStrokePoints) { return List.unmodifiable(points); } return List.unmodifiable([ for (var index = 0; index < _maximumStrokePoints; index++) points[((points.length - 1) * index / (_maximumStrokePoints - 1)) .round()], ]); } } /// Pointer preview is isolated from command/selection state. A map widget may /// update it once per frame while a placement tool is active. class TacticalPointerPreview { TacticalPointerPreview({this.point, List stroke = const []}) : stroke = List.unmodifiable(stroke); final LocalScenePoint? point; final List stroke; } final tacticalPointerPreviewProvider = NotifierProvider( TacticalPointerPreviewController.new, ); class TacticalPointerPreviewController extends Notifier { @override TacticalPointerPreview build() => TacticalPointerPreview(); void show(LocalScenePoint point, {List stroke = const []}) => state = TacticalPointerPreview(point: point, stroke: stroke); void nudge(double dx, double dy) { final current = state.point ?? const LocalScenePoint(x: .5, y: .5); show( LocalScenePoint( x: (current.x + dx).clamp(0.0, 1.0), y: (current.y + dy).clamp(0.0, 1.0), ), ); } void clear() => state = TacticalPointerPreview(); }