diff --git a/lib/providers/agent_provider.dart b/lib/providers/agent_provider.dart index b2fd5e9c..41246dd5 100644 --- a/lib/providers/agent_provider.dart +++ b/lib/providers/agent_provider.dart @@ -232,6 +232,28 @@ class AgentProvider extends Notifier> { return true; } + bool convertViewConeAgentToPlain({required String id}) { + final newState = [...state]; + final index = PlacedWidget.getIndexByID(id, newState); + if (index < 0) return false; + final node = newState[index]; + if (node is! PlacedViewConeAgent) return false; + + newState[index] = PlacedAgent( + id: node.id, + position: node.position, + type: node.type, + isAlly: node.isAlly, + state: node.state, + )..isDeleted = node.isDeleted; + + ref.read(actionProvider.notifier).addAction( + UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + ); + state = newState; + return true; + } + bool convertPlainAgentToCircle({ required String id, required double diameterMeters, diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index f35dc405..b73ae89a 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -6,8 +6,9 @@ import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/hovered_delete_target_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/map_provider.dart'; @@ -209,6 +210,8 @@ class AgentWidget extends ConsumerWidget { ) : null; final plainAgent = placedAgentNode is PlacedAgent ? placedAgentNode : null; + final viewConeAgent = + placedAgentNode is PlacedViewConeAgent ? placedAgentNode : null; final adjacentPageCopyItems = canInteract && lineUpId == null && placedAgentNode != null ? buildAdjacentPageCopyMenuItems(ref, placedAgentNode.id) @@ -216,6 +219,7 @@ class AgentWidget extends ConsumerWidget { final hasContextMenuItemsBelow = canInteract && (lineUpId != null || (plainAgent != null && plainAgent.id.isNotEmpty) || + viewConeAgent != null || adjacentPageCopyItems.isNotEmpty); final contextMenuItems = [ if (canInteract) @@ -263,6 +267,21 @@ class AgentWidget extends ConsumerWidget { ref.read(lineUpProvider.notifier).deleteGroupById(lineUpId!); }, ), + if (canInteract && viewConeAgent != null) + ShadContextMenuItem( + leading: const Icon(LucideIcons.eyeOff), + child: const Text('Remove View Cone'), + onPressed: () { + ref.read(actionProvider.notifier).performTransaction( + groups: const [ActionGroup.agent], + mutation: () { + ref + .read(agentProvider.notifier) + .convertViewConeAgentToPlain(id: viewConeAgent.id); + }, + ); + }, + ), if (canInteract && lineUpId == null && plainAgent != null && diff --git a/test/remove_view_cone_test.dart b/test/remove_view_cone_test.dart new file mode 100644 index 00000000..67b90cda --- /dev/null +++ b/test/remove_view_cone_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/page_transition/transition_planner.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; + +class _NoopActionProvider extends ActionProvider { + @override + List build() => []; + + @override + void addAction(UserAction action) { + state = [...state, action]; + } +} + +void main() { + test('removing a view cone preserves the underlying agent', () { + final container = ProviderContainer( + overrides: [ + actionProvider.overrideWith(_NoopActionProvider.new), + ], + ); + addTearDown(container.dispose); + + final original = PlacedViewConeAgent( + id: 'view-cone-agent', + type: AgentType.sova, + position: const Offset(180, 220), + isAlly: false, + state: AgentState.dead, + presetType: UtilityType.viewCone90, + rotation: 0.75, + length: 90, + visionElevation: 2, + )..isDeleted = true; + final notifier = container.read(agentProvider.notifier); + notifier.fromHive([original]); + + expect(notifier.convertViewConeAgentToPlain(id: original.id), isTrue); + + final converted = container.read(agentProvider).single; + expect(converted, isA()); + expect(converted.id, original.id); + expect(converted.type, original.type); + expect(converted.position, original.position); + expect(converted.isAlly, original.isAlly); + expect(converted.state, original.state); + expect(converted.isDeleted, original.isDeleted); + }); + + test('removing a view cone ignores plain agents', () { + final container = ProviderContainer( + overrides: [ + actionProvider.overrideWith(_NoopActionProvider.new), + ], + ); + addTearDown(container.dispose); + + final original = PlacedAgent( + id: 'plain-agent', + type: AgentType.jett, + position: const Offset(100, 120), + ); + final notifier = container.read(agentProvider.notifier); + notifier.fromHive([original]); + + expect(notifier.convertViewConeAgentToPlain(id: original.id), isFalse); + expect(container.read(agentProvider).single, same(original)); + expect(container.read(actionProvider), isEmpty); + }); + + test('view-cone removal maps the same agent through page transitions', () { + final withCone = PlacedViewConeAgent( + id: 'transition-agent', + type: AgentType.sova, + position: const Offset(200, 300), + presetType: UtilityType.viewCone90, + rotation: 0.5, + length: 75, + ); + final withoutCone = PlacedAgent( + id: withCone.id, + type: withCone.type, + position: withCone.position, + isAlly: withCone.isAlly, + state: withCone.state, + ); + + final removal = TransitionPlanner.diff( + {withCone.id: withCone}, + {withoutCone.id: withoutCone}, + ).single; + expect(removal.kind, TransitionKind.move); + expect(removal.id, withCone.id); + expect(removal.from, same(withCone)); + expect(removal.to, same(withoutCone)); + + final attachment = TransitionPlanner.diff( + {withoutCone.id: withoutCone}, + {withCone.id: withCone}, + ).single; + expect(attachment.kind, TransitionKind.move); + expect(attachment.id, withCone.id); + expect(attachment.from, same(withoutCone)); + expect(attachment.to, same(withCone)); + }); +} diff --git a/test/view_cone_agent_drag_feedback_test.dart b/test/view_cone_agent_drag_feedback_test.dart index a22b1b50..4ffd13bb 100644 --- a/test/view_cone_agent_drag_feedback_test.dart +++ b/test/view_cone_agent_drag_feedback_test.dart @@ -7,6 +7,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; @@ -79,7 +80,8 @@ void main() { expect(feedbackCone.worldOrigin, isNull); }); - testWidgets('view-cone agent menu hides elevation controls', (tester) async { + testWidgets('view-cone agent menu removes the cone with undo support', + (tester) async { CoordinateSystem(playAreaSize: const Size(1920, 1080)); final container = ProviderContainer( overrides: [ @@ -129,9 +131,9 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 250)); - expect(find.byType(ShadContextMenuItem), findsOneWidget); - expect(tester.getSize(find.byType(ShadContextMenuItem)).height, 36); - final menuItemRect = tester.getRect(find.byType(ShadContextMenuItem)); + expect(find.byType(ShadContextMenuItem), findsNWidgets(2)); + expect(tester.getSize(find.byType(ShadContextMenuItem).first).height, 40); + final menuItemRect = tester.getRect(find.byType(ShadContextMenuItem).first); final abilityButtons = find.byWidgetPredicate( (widget) => widget is Draggable, ); @@ -141,17 +143,42 @@ void main() { find.byElementPredicate((candidate) => candidate == element), ), ]; - final spaces = [ - buttonRects.first.left - menuItemRect.left, - for (var index = 1; index < buttonRects.length; index++) - buttonRects[index].left - buttonRects[index - 1].right, - menuItemRect.right - buttonRects.last.right, - ]; - for (final space in spaces) { - expect(space, closeTo(4, 0.1)); + final leadingSpace = buttonRects.first.left - menuItemRect.left; + final trailingSpace = menuItemRect.right - buttonRects.last.right; + for (var index = 1; index < buttonRects.length; index++) { + final gap = buttonRects[index].left - buttonRects[index - 1].right; + expect(gap, closeTo(4, 0.1)); } + expect(trailingSpace, closeTo(leadingSpace, 0.1)); expect(find.text('View elevation'), findsNothing); expect(find.text('Vision calibration'), findsNothing); + expect(find.text('Remove View Cone'), findsOneWidget); + + await tester.tap(find.text('Remove View Cone')); + await tester.pumpAndSettle(); + + final plainAgent = container.read(agentProvider).single; + expect(plainAgent, isA()); + expect(plainAgent.id, agent.id); + expect(plainAgent.position, agent.position); + expect(plainAgent.type, agent.type); + expect(plainAgent.isAlly, agent.isAlly); + expect(plainAgent.state, agent.state); + + container.read(actionProvider.notifier).undoAction(); + await tester.pump(); + final restoredAgent = container.read(agentProvider).single; + expect(restoredAgent, isA()); + expect((restoredAgent as PlacedViewConeAgent).presetType, agent.presetType); + expect(restoredAgent.rotation, agent.rotation); + expect(restoredAgent.length, agent.length); + + container.read(actionProvider.notifier).redoAction(); + await tester.pump(); + expect(container.read(agentProvider).single, isA()); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); }); testWidgets('free view-cone menu hides elevation controls', (tester) async {