diff --git a/ground-suite/lib/features/command/presentation/tactical_controller.dart b/ground-suite/lib/features/command/presentation/tactical_controller.dart new file mode 100644 index 0000000..ce8cd43 --- /dev/null +++ b/ground-suite/lib/features/command/presentation/tactical_controller.dart @@ -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 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(); +} diff --git a/ground-suite/test/features/command/tactical_controller_test.dart b/ground-suite/test/features/command/tactical_controller_test.dart new file mode 100644 index 0000000..fe33b6e --- /dev/null +++ b/ground-suite/test/features/command/tactical_controller_test.dart @@ -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(), + ); + 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( + 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()); + 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()); + 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(), + ); + + controller.beginPencil(); + expect( + scope.read(tacticalControllerProvider).mode, + isA(), + ); + 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()); + }); + + 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()); + 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(), + ); + }, + ); +}