feat(ground-suite): add tactical interaction state
This commit is contained in:
1 parent
cc9e9919db
commit
00a3a9f908
2 files changed
+925
No files matched your search
@@ -0,0 +1,616 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ground_suite/features/command/presentation/tactical_command_page.dart';
|
||||
import 'package:ground_suite/features/command/presentation/tactical_controller.dart';
|
||||
import 'package:ground_suite/features/fleet/domain/fleet_unit.dart';
|
||||
import 'package:ground_suite/features/vehicle_workspace/domain/vehicle_catalog.dart';
|
||||
|
||||
void main() {
|
||||
ProviderContainer container() {
|
||||
final value = ProviderContainer();
|
||||
addTearDown(value.dispose);
|
||||
return value;
|
||||
}
|
||||
|
||||
test('selection and cursor modes have one deterministic owner', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
|
||||
controller.selectUnit('DEMO-1022');
|
||||
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
|
||||
'DEMO-1022',
|
||||
});
|
||||
|
||||
controller.selectUnit('DEMO-1023', toggle: true);
|
||||
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
|
||||
'DEMO-1022',
|
||||
'DEMO-1023',
|
||||
});
|
||||
|
||||
controller.beginPencil();
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).mode,
|
||||
isA<TacticalPencilMode>(),
|
||||
);
|
||||
expect(scope.read(tacticalControllerProvider).selectedUnitIds, isEmpty);
|
||||
|
||||
controller.showUnitHangar();
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).panel,
|
||||
TacticalPanel.unitHangar,
|
||||
);
|
||||
expect(scope.read(tacticalControllerProvider).unitDraft, isNull);
|
||||
|
||||
final definition = supportedVehicleDefinitions.first;
|
||||
controller.chooseUnitDefinition(definition, displayName: 'QUADCOPTER-01');
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).unitDraft?.definition,
|
||||
definition,
|
||||
);
|
||||
controller.beginUnitSetup();
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).panel,
|
||||
TacticalPanel.unitSetup,
|
||||
);
|
||||
controller.completeUnitPlacement('local-unit-1');
|
||||
expect(scope.read(tacticalControllerProvider).selectedUnitIds, {
|
||||
'local-unit-1',
|
||||
});
|
||||
expect(scope.read(tacticalControllerProvider).unitDraft, isNull);
|
||||
controller.showUnitSetupForSelectedLocalUnit('local-unit-1');
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).panel,
|
||||
TacticalPanel.unitSetup,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'hangar drafts preserve the selected type and do not mutate map state',
|
||||
() {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
final before = scope.read(tacticalControllerProvider);
|
||||
|
||||
controller.showUnitHangar();
|
||||
controller.chooseUnitDefinition(
|
||||
supportedVehicleDefinitions[1],
|
||||
displayName: 'Fixed wing scout',
|
||||
);
|
||||
controller.beginUnitSetup();
|
||||
controller.updateUnitDraftName('Fixed wing north');
|
||||
|
||||
final editing = scope.read(tacticalControllerProvider);
|
||||
expect(
|
||||
identical(editing.selectedUnitIds, before.selectedUnitIds),
|
||||
isTrue,
|
||||
);
|
||||
expect(identical(editing.drawings, before.drawings), isTrue);
|
||||
expect(identical(editing.markers, before.markers), isTrue);
|
||||
expect(editing.panel, TacticalPanel.unitSetup);
|
||||
|
||||
controller.returnToUnitHangar();
|
||||
final returned = scope.read(tacticalControllerProvider);
|
||||
expect(returned.panel, TacticalPanel.unitHangar);
|
||||
expect(returned.unitDraft?.definition, supportedVehicleDefinitions[1]);
|
||||
expect(returned.unitDraft?.displayName, 'Fixed wing north');
|
||||
|
||||
controller.showUnitSetupForSelectedLocalUnit('local-unit-1');
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).panel,
|
||||
TacticalPanel.unitHangar,
|
||||
reason:
|
||||
'A catalog draft cannot produce an unrelated blank setup panel.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'editing a hangar draft does not notify the tactical render projection',
|
||||
() async {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
scope.read(tacticalSceneRenderStateProvider);
|
||||
var renderNotifications = 0;
|
||||
final subscription = scope.listen<TacticalSceneRenderState>(
|
||||
tacticalSceneRenderStateProvider,
|
||||
(previous, next) => renderNotifications += 1,
|
||||
fireImmediately: true,
|
||||
);
|
||||
addTearDown(subscription.close);
|
||||
renderNotifications = 0;
|
||||
|
||||
controller.showUnitHangar();
|
||||
await scope.pump();
|
||||
controller.chooseUnitDefinition(
|
||||
supportedVehicleDefinitions.first,
|
||||
displayName: 'Quad local',
|
||||
);
|
||||
await scope.pump();
|
||||
controller.updateUnitDraftName('Quad north');
|
||||
await scope.pump();
|
||||
expect(
|
||||
renderNotifications,
|
||||
1,
|
||||
reason: 'Opening the hangar changes its panel visibility.',
|
||||
);
|
||||
|
||||
controller.updateUnitDraftName('Quad north one');
|
||||
await scope.pump();
|
||||
expect(
|
||||
renderNotifications,
|
||||
1,
|
||||
reason: 'Typing only updates the isolated local setup draft.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'direct pencil strokes are session state and require complete geometry',
|
||||
() {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
|
||||
controller.beginPencil();
|
||||
expect(
|
||||
controller.commitPencilStroke(const [LocalScenePoint(x: .1, y: .2)]),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
controller.commitPencilStroke(const [
|
||||
LocalScenePoint(x: .1, y: .2),
|
||||
LocalScenePoint(x: .3, y: .4),
|
||||
]),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final state = scope.read(tacticalControllerProvider);
|
||||
expect(state.mode, isA<TacticalPencilMode>());
|
||||
expect(state.drawings, hasLength(1));
|
||||
expect(state.drawings.single.thickness, 3);
|
||||
|
||||
controller.clearSceneObjects();
|
||||
expect(scope.read(tacticalControllerProvider).drawings, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('view toggles remain separate from selection and pencil tools', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
|
||||
controller.beginPencil();
|
||||
controller.toggleOverlay(TacticalMapOverlay.drones, false);
|
||||
|
||||
final state = scope.read(tacticalControllerProvider);
|
||||
expect(state.mode, isA<TacticalPencilMode>());
|
||||
expect(state.overlays, isNot(contains(TacticalMapOverlay.drones)));
|
||||
});
|
||||
|
||||
test('fleet panel is separate from a local map mode', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
|
||||
controller.showFleetRoster();
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).panel,
|
||||
TacticalPanel.fleetRoster,
|
||||
);
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).mode,
|
||||
isA<TacticalBrowseMode>(),
|
||||
);
|
||||
|
||||
controller.beginPencil();
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).mode,
|
||||
isA<TacticalPencilMode>(),
|
||||
);
|
||||
controller.closePanel();
|
||||
expect(scope.read(tacticalControllerProvider).panel, isNull);
|
||||
});
|
||||
|
||||
test('drag selection is local only', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
controller.selectUnits(['DEMO-1021', 'DEMO-1023']);
|
||||
final state = scope.read(tacticalControllerProvider);
|
||||
expect(state.selectedUnitIds, {'DEMO-1021', 'DEMO-1023'});
|
||||
expect(state.mode, isA<TacticalBrowseMode>());
|
||||
});
|
||||
|
||||
test('direct strokes are bounded before local scene commit', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
controller.beginPencil();
|
||||
|
||||
final stroke = [
|
||||
for (var index = 0; index < 256; index++)
|
||||
LocalScenePoint(x: index / 255, y: index.isEven ? .4 : .6),
|
||||
];
|
||||
expect(controller.commitPencilStroke(stroke), isTrue);
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).drawings.single.points.length,
|
||||
lessThanOrEqualTo(256),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'pencil style is retained for the current cursor and committed stroke',
|
||||
() {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
controller.beginPencil();
|
||||
controller.configurePencil(
|
||||
hue: 282,
|
||||
saturation: .22,
|
||||
brightness: .38,
|
||||
thickness: 8,
|
||||
);
|
||||
expect(
|
||||
controller.commitPencilStroke(const [
|
||||
LocalScenePoint(x: .2, y: .2),
|
||||
LocalScenePoint(x: .4, y: .4),
|
||||
]),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final state = scope.read(tacticalControllerProvider);
|
||||
expect(state.mode, isA<TacticalPencilMode>());
|
||||
final mode = state.mode as TacticalPencilMode;
|
||||
expect(mode.hue, 282);
|
||||
expect(mode.saturation, .22);
|
||||
expect(mode.brightness, .38);
|
||||
expect(mode.thickness, 8);
|
||||
expect(state.drawings.single.hue, 282);
|
||||
expect(state.drawings.single.saturation, .22);
|
||||
expect(state.drawings.single.brightness, .38);
|
||||
expect(state.drawings.single.thickness, 8);
|
||||
},
|
||||
);
|
||||
|
||||
test('marker names and colors are independent from pencil strokes', () {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
|
||||
controller.beginPencil();
|
||||
controller.configurePencil(hue: 282, thickness: 8);
|
||||
controller.beginMarker();
|
||||
controller.configureMarker(hue: 26, saturation: .41, brightness: .56);
|
||||
controller.chooseMarkerPoint(const LocalScenePoint(x: .4, y: .6));
|
||||
|
||||
expect(controller.commitMarkerName('Landing review'), isTrue);
|
||||
final state = scope.read(tacticalControllerProvider);
|
||||
expect(state.markers, hasLength(1));
|
||||
expect(state.markers.single.name, 'Landing review');
|
||||
expect(state.markers.single.hue, 26);
|
||||
expect(state.markers.single.saturation, .41);
|
||||
expect(state.markers.single.brightness, .56);
|
||||
expect((state.mode as TacticalMarkerMode).hue, 26);
|
||||
expect(state.drawings, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'blank marker labels and pending marker cancellation create no marker',
|
||||
() {
|
||||
final scope = container();
|
||||
final controller = scope.read(tacticalControllerProvider.notifier);
|
||||
controller.beginMarker();
|
||||
controller.chooseMarkerPoint(const LocalScenePoint(x: .2, y: .2));
|
||||
|
||||
expect(controller.commitMarkerName(' '), isFalse);
|
||||
expect(controller.cancelActiveTool(), isFalse);
|
||||
expect(scope.read(tacticalControllerProvider).markers, isEmpty);
|
||||
expect(controller.cancelActiveTool(), isTrue);
|
||||
expect(
|
||||
scope.read(tacticalControllerProvider).mode,
|
||||
isA<TacticalBrowseMode>(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user