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

617 lines
18 KiB
Dart

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<LocalScenePoint> keyboardPoints = const [],
}) : keyboardPoints = List.unmodifiable(keyboardPoints);
final double hue;
final double saturation;
final double brightness;
final double thickness;
final List<LocalScenePoint> 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<LocalScenePoint> points,
}) : points = List.unmodifiable(points);
final String id;
final double hue;
final double saturation;
final double brightness;
final double thickness;
final List<LocalScenePoint> 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<String> selectedUnitIds;
final TacticalMode mode;
final TacticalPanel? panel;
final UnitCreationDraft? unitDraft;
final List<LocalSceneDrawing> drawings;
final List<LocalSceneMarker> markers;
final String? selectedDrawingId;
final String? selectedMarkerId;
final Set<TacticalMapOverlay> overlays;
TacticalUiState copyWith({
Set<String>? selectedUnitIds,
TacticalMode? mode,
TacticalPanel? panel,
bool clearPanel = false,
UnitCreationDraft? unitDraft,
bool clearUnitDraft = false,
List<LocalSceneDrawing>? drawings,
List<LocalSceneMarker>? markers,
String? selectedDrawingId,
String? selectedMarkerId,
bool clearSelectedDrawing = false,
bool clearSelectedMarker = false,
Set<TacticalMapOverlay>? 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, TacticalUiState>(
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<TacticalUiState> {
int _nextDrawing = 1;
int _nextMarker = 1;
@override
TacticalUiState build() => TacticalUiState.initial();
void selectUnit(String unitId, {bool toggle = false}) {
final selected = toggle ? {...state.selectedUnitIds} : <String>{};
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<String> 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<LocalScenePoint> 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<LocalScenePoint> 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<LocalScenePoint> _decimateStroke(List<LocalScenePoint> 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<LocalScenePoint> stroke = const []})
: stroke = List.unmodifiable(stroke);
final LocalScenePoint? point;
final List<LocalScenePoint> stroke;
}
final tacticalPointerPreviewProvider =
NotifierProvider<TacticalPointerPreviewController, TacticalPointerPreview>(
TacticalPointerPreviewController.new,
);
class TacticalPointerPreviewController
extends Notifier<TacticalPointerPreview> {
@override
TacticalPointerPreview build() => TacticalPointerPreview();
void show(LocalScenePoint point, {List<LocalScenePoint> 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();
}